From 5bff4fb5c031e59ce0cc0c559f06b8d0d2da534d Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Date: Mon, 24 Aug 2026 12:37:23 +0900 Subject: [PATCH 01/25] test(ui): add custom trace fixture Co-authored-by: OpenCode (gpt-5.6-sol) --- pnpm-lock.yaml | 6 ++ test/ui/fixtures/trace-custom/basic.test.ts | 60 +++++++++++++++++++ .../ui/fixtures/trace-custom/vitest.config.ts | 8 +++ test/ui/package.json | 2 + 4 files changed, 76 insertions(+) create mode 100644 test/ui/fixtures/trace-custom/basic.test.ts create mode 100644 test/ui/fixtures/trace-custom/vitest.config.ts diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 673cbca610ed..4102d76076a9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1496,6 +1496,12 @@ importers: happy-dom: specifier: ^20.11.2 version: 20.11.2 + playwright: + specifier: 'catalog:' + version: 1.62.1 + rrweb-snapshot: + specifier: 2.1.1 + version: 2.1.1(patch_hash=b66b30796877352a5f887f3f4115c4e7265ddc11772af2223da4bd92293716f6) vitest: specifier: workspace:* version: link:../../packages/vitest diff --git a/test/ui/fixtures/trace-custom/basic.test.ts b/test/ui/fixtures/trace-custom/basic.test.ts new file mode 100644 index 000000000000..2043622b2da9 --- /dev/null +++ b/test/ui/fixtures/trace-custom/basic.test.ts @@ -0,0 +1,60 @@ +import type { Page } from 'playwright' +import { createRequire } from 'node:module' +import { chromium } from 'playwright' +import { onTestFinished, recordArtifact, test } from 'vitest' + +const require = createRequire(import.meta.url) +const rrwebSnapshotPath = require.resolve('rrweb-snapshot') + +test('custom trace', async ({ task }) => { + const browser = await chromium.launch() + onTestFinished(() => browser.close()) + + const page = await browser.newPage() + await page.setContent('
') + await page.addScriptTag({ path: rrwebSnapshotPath }) + + await recordTrace(page, task, 'before action', 0) + + await page.locator('main').evaluate((element) => { + element.innerHTML = '' + }) + await recordTrace(page, task, 'after action', 1) +}) + +async function recordTrace(page: Page, task: Parameters[0], name: string, startTime: number) { + const snapshot = await page.evaluate(() => { + const { snapshot } = (globalThis as any).rrwebSnapshot + const serialized = snapshot(document) + if (!serialized) { + throw new Error('Failed to serialize document') + } + return { + serialized, + viewport: { + width: globalThis.innerWidth, + height: globalThis.innerHeight, + }, + scroll: { + x: globalThis.scrollX, + y: globalThis.scrollY, + }, + pseudoClassIds: {}, + } + }) + + await recordArtifact(task, { + type: 'internal:browserTrace', + data: { + retry: 0, + repeats: 0, + recordCanvas: false, + entries: [{ + name, + kind: 'mark', + startTime, + snapshot, + }], + }, + }) +} diff --git a/test/ui/fixtures/trace-custom/vitest.config.ts b/test/ui/fixtures/trace-custom/vitest.config.ts new file mode 100644 index 000000000000..73e992e4d80c --- /dev/null +++ b/test/ui/fixtures/trace-custom/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + test: { + include: ['basic.test.ts'], + ui: true, + }, +}) diff --git a/test/ui/package.json b/test/ui/package.json index 532812d7569e..23eca5964dbc 100644 --- a/test/ui/package.json +++ b/test/ui/package.json @@ -12,6 +12,8 @@ "@vitest/browser-playwright": "workspace:*", "@vitest/browser-preview": "workspace:*", "happy-dom": "^20.11.2", + "playwright": "catalog:", + "rrweb-snapshot": "2.1.1", "vitest": "workspace:*" } } From 02f33fcc12ad92deaac2a1dedac16c38dcb267d6 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Date: Mon, 24 Aug 2026 12:38:34 +0900 Subject: [PATCH 02/25] test(ui): cover custom trace artifacts Co-authored-by: OpenCode (gpt-5.6-sol) --- test/ui/test/trace-custom.spec.ts | 47 +++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100644 test/ui/test/trace-custom.spec.ts diff --git a/test/ui/test/trace-custom.spec.ts b/test/ui/test/trace-custom.spec.ts new file mode 100644 index 000000000000..d5970dbfe5ae --- /dev/null +++ b/test/ui/test/trace-custom.spec.ts @@ -0,0 +1,47 @@ +import type { Vitest } from 'vitest/node' +import { expect, test } from '@playwright/test' +import { assertTestCounts, openExplorerItem, startVitestUi } from './helper' + +test.describe('custom trace artifact', () => { + let vitest: Vitest | undefined + let baseURL: string + + test.beforeAll(async () => { + const server = await startVitestUi({ + root: './fixtures/trace-custom', + watch: true, + ui: true, + open: false, + }) + vitest = server.vitest + baseURL = server.url + }) + + test.afterAll(async () => { + await vitest?.close() + }) + + test('replays trace recorded from a node test', async ({ page }) => { + await page.goto(baseURL) + await assertTestCounts(page, { pass: 1, fail: 0 }) + await openExplorerItem(page, 'custom trace') + + const traceView = page.getByTestId('trace-view') + await expect(traceView).toBeHidden() + + await page.getByTestId('trace-open-button').click() + await expect(traceView).toBeVisible() + + const traceSteps = traceView.getByTestId('trace-step') + await expect(traceView.getByTestId('trace-step-name')).toHaveText([ + 'before action', + 'after action', + ]) + + const traceFrame = traceView.frameLocator('iframe') + await expect(traceFrame.getByRole('button', { name: 'Before action' })).toBeVisible() + + await traceSteps.nth(1).click() + await expect(traceFrame.getByRole('button', { name: 'After action' })).toBeVisible() + }) +}) From 2217c31345848a4acc6c480ce979e14ee66fed20 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Date: Mon, 24 Aug 2026 12:42:27 +0900 Subject: [PATCH 03/25] test(ui): cover custom trace html report Co-authored-by: OpenCode (gpt-5.6-sol) --- test/ui/test/trace-custom.spec.ts | 73 +++++++++++++++++++++++-------- 1 file changed, 55 insertions(+), 18 deletions(-) diff --git a/test/ui/test/trace-custom.spec.ts b/test/ui/test/trace-custom.spec.ts index d5970dbfe5ae..2a3e1e652b5a 100644 --- a/test/ui/test/trace-custom.spec.ts +++ b/test/ui/test/trace-custom.spec.ts @@ -1,6 +1,8 @@ +import type { Page } from '@playwright/test' +import type { PreviewServer } from 'vite' import type { Vitest } from 'vitest/node' import { expect, test } from '@playwright/test' -import { assertTestCounts, openExplorerItem, startVitestUi } from './helper' +import { assertTestCounts, openExplorerItem, startHtmlReportPreview, startVitestUi } from './helper' test.describe('custom trace artifact', () => { let vitest: Vitest | undefined @@ -22,26 +24,61 @@ test.describe('custom trace artifact', () => { }) test('replays trace recorded from a node test', async ({ page }) => { - await page.goto(baseURL) - await assertTestCounts(page, { pass: 1, fail: 0 }) - await openExplorerItem(page, 'custom trace') - - const traceView = page.getByTestId('trace-view') - await expect(traceView).toBeHidden() + await testCustomTrace(page, baseURL) + }) +}) - await page.getByTestId('trace-open-button').click() - await expect(traceView).toBeVisible() +test.describe('custom trace artifact html reporter', () => { + let previewServer: PreviewServer + let baseURL: string - const traceSteps = traceView.getByTestId('trace-step') - await expect(traceView.getByTestId('trace-step-name')).toHaveText([ - 'before action', - 'after action', - ]) + test.beforeAll(async () => { + const root = './fixtures/trace-custom' + const server = await startHtmlReportPreview( + { + root, + run: true, + ui: false, + reporters: 'html', + }, + { + root, + build: { outDir: '.vitest' }, + }, + ) + previewServer = server.previewServer + baseURL = `${server.url}/` + }) - const traceFrame = traceView.frameLocator('iframe') - await expect(traceFrame.getByRole('button', { name: 'Before action' })).toBeVisible() + test.afterAll(async () => { + await previewServer.close() + }) - await traceSteps.nth(1).click() - await expect(traceFrame.getByRole('button', { name: 'After action' })).toBeVisible() + test('replays trace recorded from a node test', async ({ page }) => { + await testCustomTrace(page, baseURL) }) }) + +async function testCustomTrace(page: Page, baseURL: string) { + await page.goto(baseURL) + await assertTestCounts(page, { pass: 1, fail: 0 }) + await openExplorerItem(page, 'custom trace') + + const traceView = page.getByTestId('trace-view') + await expect(traceView).toBeHidden() + + await page.getByTestId('trace-open-button').click() + await expect(traceView).toBeVisible() + + const traceSteps = traceView.getByTestId('trace-step') + await expect(traceView.getByTestId('trace-step-name')).toHaveText([ + 'before action', + 'after action', + ]) + + const traceFrame = traceView.frameLocator('iframe') + await expect(traceFrame.getByRole('button', { name: 'Before action' })).toBeVisible() + + await traceSteps.nth(1).click() + await expect(traceFrame.getByRole('button', { name: 'After action' })).toBeVisible() +} From 46b7a5869e971bb3206961bd64a9462c6a043b9d Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Date: Mon, 24 Aug 2026 12:54:11 +0900 Subject: [PATCH 04/25] feat(ui): open trace artifacts automatically Co-authored-by: OpenCode (gpt-5.6-sol) --- packages/ui/client/composables/trace-view.ts | 4 +++- test/ui/test/trace-custom.spec.ts | 3 --- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/packages/ui/client/composables/trace-view.ts b/packages/ui/client/composables/trace-view.ts index b189ce123443..ed89c9599423 100644 --- a/packages/ui/client/composables/trace-view.ts +++ b/packages/ui/client/composables/trace-view.ts @@ -206,7 +206,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 }) diff --git a/test/ui/test/trace-custom.spec.ts b/test/ui/test/trace-custom.spec.ts index 2a3e1e652b5a..ed411873dcb7 100644 --- a/test/ui/test/trace-custom.spec.ts +++ b/test/ui/test/trace-custom.spec.ts @@ -65,9 +65,6 @@ async function testCustomTrace(page: Page, baseURL: string) { await openExplorerItem(page, 'custom trace') const traceView = page.getByTestId('trace-view') - await expect(traceView).toBeHidden() - - await page.getByTestId('trace-open-button').click() await expect(traceView).toBeVisible() const traceSteps = traceView.getByTestId('trace-step') From d696adc557fd50771e40b6f025d2ea70af5deffd Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Date: Mon, 24 Aug 2026 13:10:11 +0900 Subject: [PATCH 05/25] fix(stack): filter helper location frames Co-authored-by: OpenCode (gpt-5.6-sol) --- packages/vitest/src/runtime/runner/utils/collect.ts | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/packages/vitest/src/runtime/runner/utils/collect.ts b/packages/vitest/src/runtime/runner/utils/collect.ts index c2ac20956dd3..52f893914f37 100644 --- a/packages/vitest/src/runtime/runner/utils/collect.ts +++ b/packages/vitest/src/runtime/runner/utils/collect.ts @@ -1,5 +1,5 @@ import type { ParsedStack } from '@vitest/utils' -import { parseSingleStack } from '@vitest/utils/source-map' +import { parseStacktrace } from '@vitest/utils/source-map' export function findTestFileStackTrace(testFilePath: string, error: Error): ParsedStack | undefined { let stack: string | undefined @@ -14,12 +14,6 @@ export function findTestFileStackTrace(testFilePath: string, error: Error): Pars if (!stack) { return undefined } - // first line is the error message - const lines = stack.split('\n').slice(1) - for (const line of lines) { - const parsed = parseSingleStack(line) - if (parsed && parsed.file === testFilePath) { - return parsed - } - } + return parseStacktrace(stack, { ignoreStackEntries: [] }) + .find(stack => stack.file === testFilePath) } From 3210d6f01b54268530a660a02025784e14dbe9e0 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Date: Mon, 24 Aug 2026 13:11:21 +0900 Subject: [PATCH 06/25] test(artifacts): cover helper callsite Co-authored-by: OpenCode (gpt-5.6-sol) --- test/e2e/test/artifacts.test.ts | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/test/e2e/test/artifacts.test.ts b/test/e2e/test/artifacts.test.ts index 3e85b98c542f..21d30d9b3cf4 100644 --- a/test/e2e/test/artifacts.test.ts +++ b/test/e2e/test/artifacts.test.ts @@ -265,6 +265,33 @@ describe('API', () => { }, { globals: true }) expect(stderr).toBe('') }) + + test('recordArtifact uses vi.defineHelper callsite', async () => { + let artifact: TestArtifact | undefined + const { stderr } = await runInlineTests({ + 'basic.test.ts': ` + import { recordArtifact, test, vi } from 'vitest' + + const record = vi.defineHelper(async (task) => { + await Promise.resolve() + return recordArtifact(task, { type: 'helper' }) + }) + + test('records an artifact', async ({ task }) => { + await record(task) + }) + `, + }, { + reporters: [{ + onTestCaseResult(testCase) { + artifact = testCase.artifacts()[0] + }, + }], + }) + + expect(stderr).toBe('') + expect(artifact?.location?.line).toBe(10) + }) }) // verify artifacts don't affect reporter output From 2bbc17f014da12211627c1fd48cb3e96c7f34b35 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Date: Mon, 24 Aug 2026 13:29:35 +0900 Subject: [PATCH 07/25] feat(ui): support custom trace locations Co-authored-by: OpenCode (gpt-5.6-sol) --- packages/ui/client/components/views/ViewEditor.vue | 2 +- packages/ui/client/composables/trace-view.ts | 9 ++++++++- test/ui/fixtures/trace-custom/basic.test.ts | 6 +++--- test/ui/test/trace-custom.spec.ts | 12 ++++++++++++ 4 files changed, 24 insertions(+), 5 deletions(-) diff --git a/packages/ui/client/components/views/ViewEditor.vue b/packages/ui/client/components/views/ViewEditor.vue index 2cebb5e8c3ff..9fc3fdb7c5ee 100644 --- a/packages/ui/client/components/views/ViewEditor.vue +++ b/packages/ui/client/components/views/ViewEditor.vue @@ -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[] = [] diff --git a/packages/ui/client/composables/trace-view.ts b/packages/ui/client/composables/trace-view.ts index ed89c9599423..70cf816a473e 100644 --- a/packages/ui/client/composables/trace-view.ts +++ b/packages/ui/client/composables/trace-view.ts @@ -91,7 +91,13 @@ export function getTraceAttemptMap(artifacts: TestArtifact[]): Map ({ + ...entry, + location: entry.location ?? artifact.location, + })), + }) } const merged = new Map() @@ -222,6 +228,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 } diff --git a/test/ui/fixtures/trace-custom/basic.test.ts b/test/ui/fixtures/trace-custom/basic.test.ts index 2043622b2da9..e1c83a0178b8 100644 --- a/test/ui/fixtures/trace-custom/basic.test.ts +++ b/test/ui/fixtures/trace-custom/basic.test.ts @@ -1,7 +1,7 @@ import type { Page } from 'playwright' import { createRequire } from 'node:module' import { chromium } from 'playwright' -import { onTestFinished, recordArtifact, test } from 'vitest' +import { onTestFinished, recordArtifact, test, vi } from 'vitest' const require = createRequire(import.meta.url) const rrwebSnapshotPath = require.resolve('rrweb-snapshot') @@ -22,7 +22,7 @@ test('custom trace', async ({ task }) => { await recordTrace(page, task, 'after action', 1) }) -async function recordTrace(page: Page, task: Parameters[0], name: string, startTime: number) { +const recordTrace = vi.defineHelper(async (page: Page, task: Parameters[0], name: string, startTime: number) => { const snapshot = await page.evaluate(() => { const { snapshot } = (globalThis as any).rrwebSnapshot const serialized = snapshot(document) @@ -57,4 +57,4 @@ async function recordTrace(page: Page, task: Parameters[0 }], }, }) -} +}) diff --git a/test/ui/test/trace-custom.spec.ts b/test/ui/test/trace-custom.spec.ts index ed411873dcb7..c7c0fc6d11fd 100644 --- a/test/ui/test/trace-custom.spec.ts +++ b/test/ui/test/trace-custom.spec.ts @@ -66,6 +66,7 @@ async function testCustomTrace(page: Page, baseURL: string) { const traceView = page.getByTestId('trace-view') await expect(traceView).toBeVisible() + await expect(page.locator('#details-splitpanes')).toHaveClass(/splitpanes--horizontal/) const traceSteps = traceView.getByTestId('trace-step') await expect(traceView.getByTestId('trace-step-name')).toHaveText([ @@ -76,6 +77,17 @@ async function testCustomTrace(page: Page, baseURL: string) { const traceFrame = traceView.frameLocator('iframe') await expect(traceFrame.getByRole('button', { name: 'Before action' })).toBeVisible() + await traceSteps.nth(0).click() + const editor = page.getByTestId('editor') + const activeLine = editor.locator('.CodeMirror-activeline') + await expect(activeLine).toHaveText(/await recordTrace\(page, task, 'before action', 0\)/) + + const traceEditorMarkers = editor.getByTestId('trace-editor-marker') + await expect(traceEditorMarkers).toHaveCount(2) + await expect(traceEditorMarkers.nth(0)).toHaveAttribute('aria-current', 'step') + await traceSteps.nth(1).click() + await expect(activeLine).toHaveText(/await recordTrace\(page, task, 'after action', 1\)/) + await expect(traceEditorMarkers.nth(1)).toHaveAttribute('aria-current', 'step') await expect(traceFrame.getByRole('button', { name: 'After action' })).toBeVisible() } From cc2e4f8d02b5afeeb96fd49d274f5f7797aaab93 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Date: Tue, 25 Aug 2026 12:04:48 +0900 Subject: [PATCH 08/25] refactor(ui): extract custom trace helper Co-authored-by: OpenCode (gpt-5.6-sol) --- test/ui/fixtures/trace-custom/basic.test.ts | 51 ++------------------- test/ui/fixtures/trace-custom/trace.ts | 50 ++++++++++++++++++++ test/ui/test/trace-custom.spec.ts | 4 +- 3 files changed, 57 insertions(+), 48 deletions(-) create mode 100644 test/ui/fixtures/trace-custom/trace.ts diff --git a/test/ui/fixtures/trace-custom/basic.test.ts b/test/ui/fixtures/trace-custom/basic.test.ts index e1c83a0178b8..fabf13979f1d 100644 --- a/test/ui/fixtures/trace-custom/basic.test.ts +++ b/test/ui/fixtures/trace-custom/basic.test.ts @@ -1,10 +1,6 @@ -import type { Page } from 'playwright' -import { createRequire } from 'node:module' import { chromium } from 'playwright' -import { onTestFinished, recordArtifact, test, vi } from 'vitest' - -const require = createRequire(import.meta.url) -const rrwebSnapshotPath = require.resolve('rrweb-snapshot') +import { onTestFinished, test } from 'vitest' +import { createTraceRecorder } from './trace' test('custom trace', async ({ task }) => { const browser = await chromium.launch() @@ -12,49 +8,12 @@ test('custom trace', async ({ task }) => { const page = await browser.newPage() await page.setContent('
') - await page.addScriptTag({ path: rrwebSnapshotPath }) + const trace = await createTraceRecorder(page, task) - await recordTrace(page, task, 'before action', 0) + await trace.snapshot('before action') await page.locator('main').evaluate((element) => { element.innerHTML = '' }) - await recordTrace(page, task, 'after action', 1) -}) - -const recordTrace = vi.defineHelper(async (page: Page, task: Parameters[0], name: string, startTime: number) => { - const snapshot = await page.evaluate(() => { - const { snapshot } = (globalThis as any).rrwebSnapshot - const serialized = snapshot(document) - if (!serialized) { - throw new Error('Failed to serialize document') - } - return { - serialized, - viewport: { - width: globalThis.innerWidth, - height: globalThis.innerHeight, - }, - scroll: { - x: globalThis.scrollX, - y: globalThis.scrollY, - }, - pseudoClassIds: {}, - } - }) - - await recordArtifact(task, { - type: 'internal:browserTrace', - data: { - retry: 0, - repeats: 0, - recordCanvas: false, - entries: [{ - name, - kind: 'mark', - startTime, - snapshot, - }], - }, - }) + await trace.snapshot('after action') }) diff --git a/test/ui/fixtures/trace-custom/trace.ts b/test/ui/fixtures/trace-custom/trace.ts new file mode 100644 index 000000000000..5456f5f2893a --- /dev/null +++ b/test/ui/fixtures/trace-custom/trace.ts @@ -0,0 +1,50 @@ +import type { Page } from 'playwright' +import { createRequire } from 'node:module' +import { recordArtifact, vi } from 'vitest' + +const require = createRequire(import.meta.url) +const rrwebSnapshotPath = require.resolve('rrweb-snapshot') + +export async function createTraceRecorder(page: Page, task: Parameters[0]) { + await page.addScriptTag({ path: rrwebSnapshotPath }) + const startTime = performance.now() + + return { + snapshot: vi.defineHelper(async (name: string) => { + const snapshot = await page.evaluate(() => { + const { snapshot } = (globalThis as any).rrwebSnapshot + const serialized = snapshot(document) + if (!serialized) { + throw new Error('Failed to serialize document') + } + return { + serialized, + viewport: { + width: globalThis.innerWidth, + height: globalThis.innerHeight, + }, + scroll: { + x: globalThis.scrollX, + y: globalThis.scrollY, + }, + pseudoClassIds: {}, + } + }) + + await recordArtifact(task, { + type: 'internal:browserTrace', + data: { + retry: 0, + repeats: 0, + recordCanvas: false, + entries: [{ + name, + kind: 'mark', + startTime: performance.now() - startTime, + snapshot, + }], + }, + }) + }), + } +} diff --git a/test/ui/test/trace-custom.spec.ts b/test/ui/test/trace-custom.spec.ts index c7c0fc6d11fd..2c8a1a28c02c 100644 --- a/test/ui/test/trace-custom.spec.ts +++ b/test/ui/test/trace-custom.spec.ts @@ -80,14 +80,14 @@ async function testCustomTrace(page: Page, baseURL: string) { await traceSteps.nth(0).click() const editor = page.getByTestId('editor') const activeLine = editor.locator('.CodeMirror-activeline') - await expect(activeLine).toHaveText(/await recordTrace\(page, task, 'before action', 0\)/) + await expect(activeLine).toHaveText(/await trace\.snapshot\('before action'\)/) const traceEditorMarkers = editor.getByTestId('trace-editor-marker') await expect(traceEditorMarkers).toHaveCount(2) await expect(traceEditorMarkers.nth(0)).toHaveAttribute('aria-current', 'step') await traceSteps.nth(1).click() - await expect(activeLine).toHaveText(/await recordTrace\(page, task, 'after action', 1\)/) + await expect(activeLine).toHaveText(/await trace\.snapshot\('after action'\)/) await expect(traceEditorMarkers.nth(1)).toHaveAttribute('aria-current', 'step') await expect(traceFrame.getByRole('button', { name: 'After action' })).toBeVisible() } From 87db0c4628198f7b2c3412d65e03f5eecb363473 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Date: Tue, 25 Aug 2026 12:17:51 +0900 Subject: [PATCH 09/25] refactor(ui): refine custom trace recorder Co-authored-by: OpenCode (gpt-5.6-sol) --- test/ui/fixtures/trace-custom/trace.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/test/ui/fixtures/trace-custom/trace.ts b/test/ui/fixtures/trace-custom/trace.ts index 5456f5f2893a..823a1910e68f 100644 --- a/test/ui/fixtures/trace-custom/trace.ts +++ b/test/ui/fixtures/trace-custom/trace.ts @@ -1,16 +1,20 @@ import type { Page } from 'playwright' +import type { TestContext } from 'vitest' import { createRequire } from 'node:module' import { recordArtifact, vi } from 'vitest' const require = createRequire(import.meta.url) const rrwebSnapshotPath = require.resolve('rrweb-snapshot') -export async function createTraceRecorder(page: Page, task: Parameters[0]) { +export async function createTraceRecorder(page: Page, task: TestContext['task']) { await page.addScriptTag({ path: rrwebSnapshotPath }) - const startTime = performance.now() + // TODO: Initialize per attempt from a runner hook, trace fixture, or early + // beforeEach; task.result.startTime spans all retry/repeat attempts. + const traceStartTime = performance.now() return { snapshot: vi.defineHelper(async (name: string) => { + const startTime = performance.now() - traceStartTime const snapshot = await page.evaluate(() => { const { snapshot } = (globalThis as any).rrwebSnapshot const serialized = snapshot(document) @@ -40,7 +44,7 @@ export async function createTraceRecorder(page: Page, task: Parameters Date: Tue, 25 Aug 2026 12:26:45 +0900 Subject: [PATCH 10/25] test(ui): document trace attempt metadata Co-authored-by: OpenCode (gpt-5.6-sol) --- test/ui/fixtures/trace-custom/trace.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/test/ui/fixtures/trace-custom/trace.ts b/test/ui/fixtures/trace-custom/trace.ts index 823a1910e68f..2b054200ba62 100644 --- a/test/ui/fixtures/trace-custom/trace.ts +++ b/test/ui/fixtures/trace-custom/trace.ts @@ -9,7 +9,8 @@ const rrwebSnapshotPath = require.resolve('rrweb-snapshot') export async function createTraceRecorder(page: Page, task: TestContext['task']) { await page.addScriptTag({ path: rrwebSnapshotPath }) // TODO: Initialize per attempt from a runner hook, trace fixture, or early - // beforeEach; task.result.startTime spans all retry/repeat attempts. + // beforeEach. task.result.startTime spans all attempts, and retryCount is not + // reset between repeats, so the task result cannot identify an attempt exactly. const traceStartTime = performance.now() return { From 87648f7e37781a7e723c63f97bce063b56790ed8 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Date: Tue, 25 Aug 2026 12:27:12 +0900 Subject: [PATCH 11/25] nit --- test/ui/fixtures/trace-custom/vitest.config.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/test/ui/fixtures/trace-custom/vitest.config.ts b/test/ui/fixtures/trace-custom/vitest.config.ts index 73e992e4d80c..539cdfd5b620 100644 --- a/test/ui/fixtures/trace-custom/vitest.config.ts +++ b/test/ui/fixtures/trace-custom/vitest.config.ts @@ -2,7 +2,6 @@ import { defineConfig } from 'vitest/config' export default defineConfig({ test: { - include: ['basic.test.ts'], ui: true, }, }) From d373cde17995f272c3e73cbcd2c948433e78cdff Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Date: Tue, 25 Aug 2026 12:54:29 +0900 Subject: [PATCH 12/25] test(ui): prototype custom trace runner Co-authored-by: OpenCode (gpt-5.6-sol) --- .../ui/fixtures/trace-custom/attempts.test.ts | 19 ++++++++++++ test/ui/fixtures/trace-custom/basic.test.ts | 9 +++--- .../ui/fixtures/trace-custom/trace/attempt.ts | 27 +++++++++++++++++ .../{trace.ts => trace/recorder.ts} | 22 +++++++++----- test/ui/fixtures/trace-custom/trace/runner.ts | 30 +++++++++++++++++++ .../ui/fixtures/trace-custom/vitest.config.ts | 1 + test/ui/test/trace-custom.spec.ts | 21 +++++++++++-- 7 files changed, 113 insertions(+), 16 deletions(-) create mode 100644 test/ui/fixtures/trace-custom/attempts.test.ts create mode 100644 test/ui/fixtures/trace-custom/trace/attempt.ts rename test/ui/fixtures/trace-custom/{trace.ts => trace/recorder.ts} (72%) create mode 100644 test/ui/fixtures/trace-custom/trace/runner.ts diff --git a/test/ui/fixtures/trace-custom/attempts.test.ts b/test/ui/fixtures/trace-custom/attempts.test.ts new file mode 100644 index 000000000000..2ab1ece3cb99 --- /dev/null +++ b/test/ui/fixtures/trace-custom/attempts.test.ts @@ -0,0 +1,19 @@ +import { chromium } from 'playwright' +import { onTestFinished, test } from 'vitest' + +let attemptIndex = 0 + +test('custom trace attempts', { retry: 1, repeats: 1 }, async ({ trace }) => { + const currentAttempt = attemptIndex++ + const browser = await chromium.launch() + onTestFinished(() => browser.close()) + + const page = await browser.newPage() + await page.setContent(`
Attempt ${currentAttempt}
`) + const recorder = await trace(page) + await recorder.snapshot('attempt') + + if (currentAttempt % 2 === 0) { + throw new Error('Retry this attempt') + } +}) diff --git a/test/ui/fixtures/trace-custom/basic.test.ts b/test/ui/fixtures/trace-custom/basic.test.ts index fabf13979f1d..4f8d414980e8 100644 --- a/test/ui/fixtures/trace-custom/basic.test.ts +++ b/test/ui/fixtures/trace-custom/basic.test.ts @@ -1,19 +1,18 @@ import { chromium } from 'playwright' import { onTestFinished, test } from 'vitest' -import { createTraceRecorder } from './trace' -test('custom trace', async ({ task }) => { +test('custom trace', async ({ trace }) => { const browser = await chromium.launch() onTestFinished(() => browser.close()) const page = await browser.newPage() await page.setContent('
') - const trace = await createTraceRecorder(page, task) + const recorder = await trace(page) - await trace.snapshot('before action') + await recorder.snapshot('before action') await page.locator('main').evaluate((element) => { element.innerHTML = '' }) - await trace.snapshot('after action') + await recorder.snapshot('after action') }) diff --git a/test/ui/fixtures/trace-custom/trace/attempt.ts b/test/ui/fixtures/trace-custom/trace/attempt.ts new file mode 100644 index 000000000000..9f3d93b4bd2a --- /dev/null +++ b/test/ui/fixtures/trace-custom/trace/attempt.ts @@ -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] +} diff --git a/test/ui/fixtures/trace-custom/trace.ts b/test/ui/fixtures/trace-custom/trace/recorder.ts similarity index 72% rename from test/ui/fixtures/trace-custom/trace.ts rename to test/ui/fixtures/trace-custom/trace/recorder.ts index 2b054200ba62..6dc3734ce866 100644 --- a/test/ui/fixtures/trace-custom/trace.ts +++ b/test/ui/fixtures/trace-custom/trace/recorder.ts @@ -6,16 +6,22 @@ import { recordArtifact, vi } from 'vitest' const require = createRequire(import.meta.url) const rrwebSnapshotPath = require.resolve('rrweb-snapshot') -export async function createTraceRecorder(page: Page, task: TestContext['task']) { +export interface TraceAttempt { + retry: number + repeats: number + startTime: number +} + +export async function createTraceRecorder( + page: Page, + task: TestContext['task'], + attempt: TraceAttempt, +) { await page.addScriptTag({ path: rrwebSnapshotPath }) - // TODO: Initialize per attempt from a runner hook, trace fixture, or early - // beforeEach. task.result.startTime spans all attempts, and retryCount is not - // reset between repeats, so the task result cannot identify an attempt exactly. - const traceStartTime = performance.now() return { snapshot: vi.defineHelper(async (name: string) => { - const startTime = performance.now() - traceStartTime + const startTime = performance.now() - attempt.startTime const snapshot = await page.evaluate(() => { const { snapshot } = (globalThis as any).rrwebSnapshot const serialized = snapshot(document) @@ -39,8 +45,8 @@ export async function createTraceRecorder(page: Page, task: TestContext['task']) await recordArtifact(task, { type: 'internal:browserTrace', data: { - retry: 0, - repeats: 0, + retry: attempt.retry, + repeats: attempt.repeats, recordCanvas: false, entries: [{ name, diff --git a/test/ui/fixtures/trace-custom/trace/runner.ts b/test/ui/fixtures/trace-custom/trace/runner.ts new file mode 100644 index 000000000000..a3316acedf42 --- /dev/null +++ b/test/ui/fixtures/trace-custom/trace/runner.ts @@ -0,0 +1,30 @@ +import type { Page } from 'playwright' +import type { RunnerTestCase, TestContext, TestTryOptions } from 'vitest' +import { TestRunner } from 'vitest' +import { finishTraceAttempt, getTraceAttempt, startTraceAttempt } from './attempt' +import { createTraceRecorder } from './recorder' + +type TraceFactory = (page: Page) => ReturnType + +declare module 'vitest' { + interface TestContext { + trace: TraceFactory + } +} + +export default class TraceRunner extends TestRunner { + override extendTaskContext(context: TestContext): TestContext { + super.extendTaskContext(context) + context.trace = page => createTraceRecorder(page, context.task, getTraceAttempt(context.task)) + return context + } + + override onBeforeTryTask(test: RunnerTestCase, options: TestTryOptions): void { + super.onBeforeTryTask(test, options) + startTraceAttempt(test, options) + } + + onAfterRetryTask(test: RunnerTestCase): void { + finishTraceAttempt(test) + } +} diff --git a/test/ui/fixtures/trace-custom/vitest.config.ts b/test/ui/fixtures/trace-custom/vitest.config.ts index 539cdfd5b620..a419eded8694 100644 --- a/test/ui/fixtures/trace-custom/vitest.config.ts +++ b/test/ui/fixtures/trace-custom/vitest.config.ts @@ -2,6 +2,7 @@ import { defineConfig } from 'vitest/config' export default defineConfig({ test: { + runner: './trace/runner.ts', ui: true, }, }) diff --git a/test/ui/test/trace-custom.spec.ts b/test/ui/test/trace-custom.spec.ts index 2c8a1a28c02c..b0f2092789b1 100644 --- a/test/ui/test/trace-custom.spec.ts +++ b/test/ui/test/trace-custom.spec.ts @@ -61,7 +61,7 @@ test.describe('custom trace artifact html reporter', () => { async function testCustomTrace(page: Page, baseURL: string) { await page.goto(baseURL) - await assertTestCounts(page, { pass: 1, fail: 0 }) + await assertTestCounts(page, { pass: 2, fail: 0 }) await openExplorerItem(page, 'custom trace') const traceView = page.getByTestId('trace-view') @@ -80,14 +80,29 @@ async function testCustomTrace(page: Page, baseURL: string) { await traceSteps.nth(0).click() const editor = page.getByTestId('editor') const activeLine = editor.locator('.CodeMirror-activeline') - await expect(activeLine).toHaveText(/await trace\.snapshot\('before action'\)/) + await expect(activeLine).toHaveText(/await recorder\.snapshot\('before action'\)/) const traceEditorMarkers = editor.getByTestId('trace-editor-marker') await expect(traceEditorMarkers).toHaveCount(2) await expect(traceEditorMarkers.nth(0)).toHaveAttribute('aria-current', 'step') await traceSteps.nth(1).click() - await expect(activeLine).toHaveText(/await trace\.snapshot\('after action'\)/) + await expect(activeLine).toHaveText(/await recorder\.snapshot\('after action'\)/) await expect(traceEditorMarkers.nth(1)).toHaveAttribute('aria-current', 'step') await expect(traceFrame.getByRole('button', { name: 'After action' })).toBeVisible() + + await page.goto(baseURL) + await openExplorerItem(page, 'custom trace attempts') + const traceOpenButtons = page.getByTestId('trace-open-button') + await expect(traceOpenButtons).toHaveText([ + 'Open trace viewer', + 'Open trace viewer Retry 1', + 'Open trace viewer Repeat 1', + 'Open trace viewer Retry 1 / Repeat 1', + ]) + + for (let index = 0; index < 4; index++) { + await traceOpenButtons.nth(index).click() + await expect(traceView.getByTestId('trace-step-name')).toHaveText('attempt') + } } From de4529f3f043b7da4157104618d41d5f8876c6b9 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Date: Tue, 25 Aug 2026 13:03:23 +0900 Subject: [PATCH 13/25] test(ui): prototype custom trace marks Co-authored-by: OpenCode (gpt-5.6-sol) --- test/ui/fixtures/trace-custom/basic.test.ts | 13 +- .../fixtures/trace-custom/trace/recorder.ts | 132 +++++++++++++----- test/ui/test/trace-custom.spec.ts | 6 +- 3 files changed, 107 insertions(+), 44 deletions(-) diff --git a/test/ui/fixtures/trace-custom/basic.test.ts b/test/ui/fixtures/trace-custom/basic.test.ts index 4f8d414980e8..57c2067768df 100644 --- a/test/ui/fixtures/trace-custom/basic.test.ts +++ b/test/ui/fixtures/trace-custom/basic.test.ts @@ -1,5 +1,5 @@ import { chromium } from 'playwright' -import { onTestFinished, test } from 'vitest' +import { expect, onTestFinished, test } from 'vitest' test('custom trace', async ({ trace }) => { const browser = await chromium.launch() @@ -11,8 +11,11 @@ test('custom trace', async ({ trace }) => { await recorder.snapshot('before action') - await page.locator('main').evaluate((element) => { - element.innerHTML = '' - }) - await recorder.snapshot('after action') + const result = await recorder.mark('action', async () => { + await page.locator('main').evaluate((element) => { + element.innerHTML = '' + }) + return 'action result' + }, { kind: 'action' }) + expect(result).toBe('action result') }) diff --git a/test/ui/fixtures/trace-custom/trace/recorder.ts b/test/ui/fixtures/trace-custom/trace/recorder.ts index 6dc3734ce866..9f4805c28e18 100644 --- a/test/ui/fixtures/trace-custom/trace/recorder.ts +++ b/test/ui/fixtures/trace-custom/trace/recorder.ts @@ -1,5 +1,6 @@ import type { Page } from 'playwright' import type { TestContext } from 'vitest' +import type { MarkOptions } from 'vitest/browser' import { createRequire } from 'node:module' import { recordArtifact, vi } from 'vitest' @@ -12,50 +13,107 @@ export interface TraceAttempt { startTime: number } +interface SnapshotEntryOptions extends MarkOptions { + range?: { + id: string + phase: 'start' | 'end' + } + status?: 'pass' | 'fail' +} + +export interface TraceRecorder { + snapshot: (name: string, options?: MarkOptions) => Promise + mark: { + (name: string, options?: MarkOptions): Promise + (name: string, body: () => T | Promise, options?: MarkOptions): Promise + } +} + export async function createTraceRecorder( page: Page, task: TestContext['task'], attempt: TraceAttempt, -) { +): Promise { await page.addScriptTag({ path: rrwebSnapshotPath }) - return { - snapshot: vi.defineHelper(async (name: string) => { - const startTime = performance.now() - attempt.startTime - const snapshot = await page.evaluate(() => { - const { snapshot } = (globalThis as any).rrwebSnapshot - const serialized = snapshot(document) - if (!serialized) { - throw new Error('Failed to serialize document') - } - return { - serialized, - viewport: { - width: globalThis.innerWidth, - height: globalThis.innerHeight, - }, - scroll: { - x: globalThis.scrollX, - y: globalThis.scrollY, - }, - pseudoClassIds: {}, - } - }) - - await recordArtifact(task, { - type: 'internal:browserTrace', - data: { - retry: attempt.retry, - repeats: attempt.repeats, - recordCanvas: false, - entries: [{ - name, - kind: 'mark', - startTime, - snapshot, - }], + async function recordSnapshot(name: string, options: SnapshotEntryOptions = {}): Promise { + const startTime = performance.now() - attempt.startTime + const snapshot = await page.evaluate(() => { + const { snapshot } = (globalThis as any).rrwebSnapshot + const serialized = snapshot(document) + if (!serialized) { + throw new Error('Failed to serialize document') + } + return { + serialized, + viewport: { + width: globalThis.innerWidth, + height: globalThis.innerHeight, }, + scroll: { + x: globalThis.scrollX, + y: globalThis.scrollY, + }, + pseudoClassIds: {}, + } + }) + + await recordArtifact(task, { + type: 'internal:browserTrace', + data: { + retry: attempt.retry, + repeats: attempt.repeats, + recordCanvas: false, + entries: [{ + name, + kind: options.kind ?? 'mark', + startTime, + snapshot, + ...(options.range ? { range: options.range } : {}), + ...(options.status ? { status: options.status } : {}), + ...(options.stack ? { stack: options.stack } : {}), + }], + }, + }) + } + + const snapshot = vi.defineHelper(recordSnapshot) + const mark = vi.defineHelper(async ( + name: string, + bodyOrOptions?: MarkOptions | (() => T | Promise), + options?: MarkOptions, + ): Promise => { + if (typeof bodyOrOptions !== 'function') { + return recordSnapshot(name, bodyOrOptions) + } + + const rangeId = Math.random().toString(36).slice(2) + await recordSnapshot(name, { + ...options, + kind: 'mark', + range: { id: rangeId, phase: 'start' }, + }) + + let status: 'pass' | 'fail' = 'pass' + try { + return await bodyOrOptions() + } + catch (error) { + status = 'fail' + throw error + } + finally { + await recordSnapshot(name, { + ...options, + kind: options?.kind, + range: { id: rangeId, phase: 'end' }, + status, }) - }), + } + }) as TraceRecorder['mark'] + + return { + snapshot, + mark, } } diff --git a/test/ui/test/trace-custom.spec.ts b/test/ui/test/trace-custom.spec.ts index b0f2092789b1..45bea042cef4 100644 --- a/test/ui/test/trace-custom.spec.ts +++ b/test/ui/test/trace-custom.spec.ts @@ -71,8 +71,10 @@ async function testCustomTrace(page: Page, baseURL: string) { const traceSteps = traceView.getByTestId('trace-step') await expect(traceView.getByTestId('trace-step-name')).toHaveText([ 'before action', - 'after action', + 'action', ]) + await expect(traceSteps.nth(1)).toHaveAttribute('data-test-range', 'end') + await expect(traceSteps.nth(1).locator('.text-blue-500')).toBeVisible() const traceFrame = traceView.frameLocator('iframe') await expect(traceFrame.getByRole('button', { name: 'Before action' })).toBeVisible() @@ -87,7 +89,7 @@ async function testCustomTrace(page: Page, baseURL: string) { await expect(traceEditorMarkers.nth(0)).toHaveAttribute('aria-current', 'step') await traceSteps.nth(1).click() - await expect(activeLine).toHaveText(/await recorder\.snapshot\('after action'\)/) + await expect(activeLine).toHaveText(/const result = await recorder\.mark\('action'/) await expect(traceEditorMarkers.nth(1)).toHaveAttribute('aria-current', 'step') await expect(traceFrame.getByRole('button', { name: 'After action' })).toBeVisible() From 594c5f1c68ccb6937b0bfd3babc23909da7a70e2 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Date: Tue, 25 Aug 2026 13:13:43 +0900 Subject: [PATCH 14/25] test(ui): prototype trace lifecycle entry Co-authored-by: OpenCode (gpt-5.6-sol) --- .../fixtures/trace-custom/trace/recorder.ts | 15 ++++++++--- test/ui/fixtures/trace-custom/trace/runner.ts | 17 +++++++++++- test/ui/test/trace-custom.spec.ts | 26 ++++++++++++++++--- 3 files changed, 50 insertions(+), 8 deletions(-) diff --git a/test/ui/fixtures/trace-custom/trace/recorder.ts b/test/ui/fixtures/trace-custom/trace/recorder.ts index 9f4805c28e18..8f6941a42bc2 100644 --- a/test/ui/fixtures/trace-custom/trace/recorder.ts +++ b/test/ui/fixtures/trace-custom/trace/recorder.ts @@ -13,16 +13,24 @@ export interface TraceAttempt { startTime: number } -interface SnapshotEntryOptions extends MarkOptions { +export interface SnapshotOptions extends MarkOptions { + location?: { + file: string + line: number + column: number + } + status?: 'pass' | 'fail' +} + +interface SnapshotEntryOptions extends SnapshotOptions { range?: { id: string phase: 'start' | 'end' } - status?: 'pass' | 'fail' } export interface TraceRecorder { - snapshot: (name: string, options?: MarkOptions) => Promise + snapshot: (name: string, options?: SnapshotOptions) => Promise mark: { (name: string, options?: MarkOptions): Promise (name: string, body: () => T | Promise, options?: MarkOptions): Promise @@ -72,6 +80,7 @@ export async function createTraceRecorder( ...(options.range ? { range: options.range } : {}), ...(options.status ? { status: options.status } : {}), ...(options.stack ? { stack: options.stack } : {}), + ...(options.location ? { location: options.location } : {}), }], }, }) diff --git a/test/ui/fixtures/trace-custom/trace/runner.ts b/test/ui/fixtures/trace-custom/trace/runner.ts index a3316acedf42..700dfb52d928 100644 --- a/test/ui/fixtures/trace-custom/trace/runner.ts +++ b/test/ui/fixtures/trace-custom/trace/runner.ts @@ -15,7 +15,22 @@ declare module 'vitest' { export default class TraceRunner extends TestRunner { override extendTaskContext(context: TestContext): TestContext { super.extendTaskContext(context) - context.trace = page => createTraceRecorder(page, context.task, getTraceAttempt(context.task)) + context.trace = async (page) => { + const recorder = await createTraceRecorder(page, context.task, getTraceAttempt(context.task)) + context.onTestFinished(async ({ task }) => { + const status = task.result?.state + const stack = status === 'fail' ? task.result?.errors?.[0].stack : undefined + const location = task.location + ? { ...task.location, file: task.file.filepath } + : undefined + await recorder.snapshot('vitest:onAfterRetryTask', { + kind: 'lifecycle', + ...(status === 'pass' || status === 'fail' ? { status } : {}), + ...(stack ? { stack } : location ? { location } : {}), + }) + }) + return recorder + } return context } diff --git a/test/ui/test/trace-custom.spec.ts b/test/ui/test/trace-custom.spec.ts index 45bea042cef4..1d690f651bb4 100644 --- a/test/ui/test/trace-custom.spec.ts +++ b/test/ui/test/trace-custom.spec.ts @@ -72,6 +72,7 @@ async function testCustomTrace(page: Page, baseURL: string) { await expect(traceView.getByTestId('trace-step-name')).toHaveText([ 'before action', 'action', + 'test finished', ]) await expect(traceSteps.nth(1)).toHaveAttribute('data-test-range', 'end') await expect(traceSteps.nth(1).locator('.text-blue-500')).toBeVisible() @@ -85,14 +86,21 @@ async function testCustomTrace(page: Page, baseURL: string) { await expect(activeLine).toHaveText(/await recorder\.snapshot\('before action'\)/) const traceEditorMarkers = editor.getByTestId('trace-editor-marker') - await expect(traceEditorMarkers).toHaveCount(2) - await expect(traceEditorMarkers.nth(0)).toHaveAttribute('aria-current', 'step') + await expect(traceEditorMarkers).toHaveCount(3) + const snapshotMarker = traceEditorMarkers.and(page.locator('[aria-label="Select trace step: before action"]')) + const actionMarker = traceEditorMarkers.and(page.locator('[aria-label="Select trace step: action"]')) + const lifecycleMarker = traceEditorMarkers.and(page.locator('[aria-label="Select trace step: vitest:onAfterRetryTask"]')) + await expect(snapshotMarker).toHaveAttribute('aria-current', 'step') await traceSteps.nth(1).click() await expect(activeLine).toHaveText(/const result = await recorder\.mark\('action'/) - await expect(traceEditorMarkers.nth(1)).toHaveAttribute('aria-current', 'step') + await expect(actionMarker).toHaveAttribute('aria-current', 'step') await expect(traceFrame.getByRole('button', { name: 'After action' })).toBeVisible() + await traceSteps.nth(2).click() + await expect(activeLine).toHaveText(/test\('custom trace'/) + await expect(lifecycleMarker).toHaveAttribute('aria-current', 'step') + await page.goto(baseURL) await openExplorerItem(page, 'custom trace attempts') const traceOpenButtons = page.getByTestId('trace-open-button') @@ -105,6 +113,16 @@ async function testCustomTrace(page: Page, baseURL: string) { for (let index = 0; index < 4; index++) { await traceOpenButtons.nth(index).click() - await expect(traceView.getByTestId('trace-step-name')).toHaveText('attempt') + await expect(traceView.getByTestId('trace-step-name')).toHaveText([ + 'attempt', + 'test finished', + ]) + const lifecycleStep = traceView.getByTestId('trace-step').nth(1) + if (index % 2 === 0) { + await expect(lifecycleStep).toHaveClass(/text-red-600/) + } + else { + await expect(lifecycleStep).not.toHaveClass(/text-red-600/) + } } } From 0e125e274fcc8733725117cab3ce58b87ad37231 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Date: Tue, 25 Aug 2026 13:15:39 +0900 Subject: [PATCH 15/25] refactor(ui): make trace finish explicit Co-authored-by: OpenCode (gpt-5.6-sol) --- test/ui/fixtures/trace-custom/attempts.test.ts | 1 + test/ui/fixtures/trace-custom/basic.test.ts | 1 + test/ui/fixtures/trace-custom/trace/recorder.ts | 14 ++++++++++++++ test/ui/fixtures/trace-custom/trace/runner.ts | 15 +-------------- 4 files changed, 17 insertions(+), 14 deletions(-) diff --git a/test/ui/fixtures/trace-custom/attempts.test.ts b/test/ui/fixtures/trace-custom/attempts.test.ts index 2ab1ece3cb99..936022b008ff 100644 --- a/test/ui/fixtures/trace-custom/attempts.test.ts +++ b/test/ui/fixtures/trace-custom/attempts.test.ts @@ -11,6 +11,7 @@ test('custom trace attempts', { retry: 1, repeats: 1 }, async ({ trace }) => { const page = await browser.newPage() await page.setContent(`
Attempt ${currentAttempt}
`) const recorder = await trace(page) + onTestFinished(() => recorder.finish()) await recorder.snapshot('attempt') if (currentAttempt % 2 === 0) { diff --git a/test/ui/fixtures/trace-custom/basic.test.ts b/test/ui/fixtures/trace-custom/basic.test.ts index 57c2067768df..22e246295dd5 100644 --- a/test/ui/fixtures/trace-custom/basic.test.ts +++ b/test/ui/fixtures/trace-custom/basic.test.ts @@ -8,6 +8,7 @@ test('custom trace', async ({ trace }) => { const page = await browser.newPage() await page.setContent('
') const recorder = await trace(page) + onTestFinished(() => recorder.finish()) await recorder.snapshot('before action') diff --git a/test/ui/fixtures/trace-custom/trace/recorder.ts b/test/ui/fixtures/trace-custom/trace/recorder.ts index 8f6941a42bc2..a0f815e18024 100644 --- a/test/ui/fixtures/trace-custom/trace/recorder.ts +++ b/test/ui/fixtures/trace-custom/trace/recorder.ts @@ -30,6 +30,7 @@ interface SnapshotEntryOptions extends SnapshotOptions { } export interface TraceRecorder { + finish: () => Promise snapshot: (name: string, options?: SnapshotOptions) => Promise mark: { (name: string, options?: MarkOptions): Promise @@ -87,6 +88,18 @@ export async function createTraceRecorder( } const snapshot = vi.defineHelper(recordSnapshot) + const finish = async (): Promise => { + const status = task.result?.state + const stack = status === 'fail' ? task.result?.errors?.[0].stack : undefined + const location = task.location + ? { ...task.location, file: task.file.filepath } + : undefined + await recordSnapshot('vitest:onAfterRetryTask', { + kind: 'lifecycle', + ...(status === 'pass' || status === 'fail' ? { status } : {}), + ...(stack ? { stack } : location ? { location } : {}), + }) + } const mark = vi.defineHelper(async ( name: string, bodyOrOptions?: MarkOptions | (() => T | Promise), @@ -122,6 +135,7 @@ export async function createTraceRecorder( }) as TraceRecorder['mark'] return { + finish, snapshot, mark, } diff --git a/test/ui/fixtures/trace-custom/trace/runner.ts b/test/ui/fixtures/trace-custom/trace/runner.ts index 700dfb52d928..ae1551911dc8 100644 --- a/test/ui/fixtures/trace-custom/trace/runner.ts +++ b/test/ui/fixtures/trace-custom/trace/runner.ts @@ -16,20 +16,7 @@ export default class TraceRunner extends TestRunner { override extendTaskContext(context: TestContext): TestContext { super.extendTaskContext(context) context.trace = async (page) => { - const recorder = await createTraceRecorder(page, context.task, getTraceAttempt(context.task)) - context.onTestFinished(async ({ task }) => { - const status = task.result?.state - const stack = status === 'fail' ? task.result?.errors?.[0].stack : undefined - const location = task.location - ? { ...task.location, file: task.file.filepath } - : undefined - await recorder.snapshot('vitest:onAfterRetryTask', { - kind: 'lifecycle', - ...(status === 'pass' || status === 'fail' ? { status } : {}), - ...(stack ? { stack } : location ? { location } : {}), - }) - }) - return recorder + return createTraceRecorder(page, context.task, getTraceAttempt(context.task)) } return context } From cc49e99f6fb1f47bf1a7f89a3d67920c8f39c7c9 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Date: Tue, 25 Aug 2026 13:28:25 +0900 Subject: [PATCH 16/25] test(ui): resolve custom trace stacks Co-authored-by: OpenCode (gpt-5.6-sol) --- test/ui/fixtures/trace-custom/trace/recorder.ts | 11 ++++++++++- test/ui/test/trace-custom.spec.ts | 4 ++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/test/ui/fixtures/trace-custom/trace/recorder.ts b/test/ui/fixtures/trace-custom/trace/recorder.ts index a0f815e18024..30de3316ea30 100644 --- a/test/ui/fixtures/trace-custom/trace/recorder.ts +++ b/test/ui/fixtures/trace-custom/trace/recorder.ts @@ -1,6 +1,7 @@ import type { Page } from 'playwright' import type { TestContext } from 'vitest' import type { MarkOptions } from 'vitest/browser' +import { parseStacktrace } from '@vitest/utils/source-map' import { createRequire } from 'node:module' import { recordArtifact, vi } from 'vitest' @@ -47,6 +48,14 @@ export async function createTraceRecorder( async function recordSnapshot(name: string, options: SnapshotEntryOptions = {}): Promise { const startTime = performance.now() - attempt.startTime + const stackLocation = options.stack ? parseStacktrace(options.stack)[0] : undefined + const location = options.location ?? (stackLocation + ? { + file: stackLocation.file, + line: stackLocation.line, + column: stackLocation.column, + } + : undefined) const snapshot = await page.evaluate(() => { const { snapshot } = (globalThis as any).rrwebSnapshot const serialized = snapshot(document) @@ -81,7 +90,7 @@ export async function createTraceRecorder( ...(options.range ? { range: options.range } : {}), ...(options.status ? { status: options.status } : {}), ...(options.stack ? { stack: options.stack } : {}), - ...(options.location ? { location: options.location } : {}), + ...(location ? { location } : {}), }], }, }) diff --git a/test/ui/test/trace-custom.spec.ts b/test/ui/test/trace-custom.spec.ts index 1d690f651bb4..f16cba410973 100644 --- a/test/ui/test/trace-custom.spec.ts +++ b/test/ui/test/trace-custom.spec.ts @@ -125,4 +125,8 @@ async function testCustomTrace(page: Page, baseURL: string) { await expect(lifecycleStep).not.toHaveClass(/text-red-600/) } } + + await traceOpenButtons.nth(0).click() + await traceView.getByTestId('trace-step').nth(1).click() + await expect(page.getByTestId('editor').locator('.CodeMirror-activeline')).toHaveText(/throw new Error\('Retry this attempt'\)/) } From f18f120f3d791a4d27622c3ec12aa225c4cf1faa Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Date: Tue, 25 Aug 2026 13:40:14 +0900 Subject: [PATCH 17/25] test(ui): prototype automatic trace fixture Co-authored-by: OpenCode (gpt-5.6-sol) --- .../ui/fixtures/trace-custom/attempts.test.ts | 12 +++------- test/ui/fixtures/trace-custom/basic.test.ts | 16 +++++--------- test/ui/fixtures/trace-custom/trace/runner.ts | 22 ++----------------- test/ui/fixtures/trace-custom/trace/test.ts | 21 ++++++++++++++++++ test/ui/test/trace-custom.spec.ts | 4 ++-- 5 files changed, 33 insertions(+), 42 deletions(-) create mode 100644 test/ui/fixtures/trace-custom/trace/test.ts diff --git a/test/ui/fixtures/trace-custom/attempts.test.ts b/test/ui/fixtures/trace-custom/attempts.test.ts index 936022b008ff..2396ab481b26 100644 --- a/test/ui/fixtures/trace-custom/attempts.test.ts +++ b/test/ui/fixtures/trace-custom/attempts.test.ts @@ -1,18 +1,12 @@ -import { chromium } from 'playwright' -import { onTestFinished, test } from 'vitest' +import { test } from './trace/test' let attemptIndex = 0 -test('custom trace attempts', { retry: 1, repeats: 1 }, async ({ trace }) => { +test('custom trace attempts', { retry: 1, repeats: 1 }, async ({ page, trace }) => { const currentAttempt = attemptIndex++ - const browser = await chromium.launch() - onTestFinished(() => browser.close()) - const page = await browser.newPage() await page.setContent(`
Attempt ${currentAttempt}
`) - const recorder = await trace(page) - onTestFinished(() => recorder.finish()) - await recorder.snapshot('attempt') + await trace.snapshot('attempt') if (currentAttempt % 2 === 0) { throw new Error('Retry this attempt') diff --git a/test/ui/fixtures/trace-custom/basic.test.ts b/test/ui/fixtures/trace-custom/basic.test.ts index 22e246295dd5..f84d1d3664e2 100644 --- a/test/ui/fixtures/trace-custom/basic.test.ts +++ b/test/ui/fixtures/trace-custom/basic.test.ts @@ -1,18 +1,12 @@ -import { chromium } from 'playwright' -import { expect, onTestFinished, test } from 'vitest' +import { expect } from 'vitest' +import { test } from './trace/test' -test('custom trace', async ({ trace }) => { - const browser = await chromium.launch() - onTestFinished(() => browser.close()) - - const page = await browser.newPage() +test('custom trace', async ({ page, trace }) => { await page.setContent('
') - const recorder = await trace(page) - onTestFinished(() => recorder.finish()) - await recorder.snapshot('before action') + await trace.snapshot('before action') - const result = await recorder.mark('action', async () => { + const result = await trace.mark('action', async () => { await page.locator('main').evaluate((element) => { element.innerHTML = '' }) diff --git a/test/ui/fixtures/trace-custom/trace/runner.ts b/test/ui/fixtures/trace-custom/trace/runner.ts index ae1551911dc8..c7d0ba6530fb 100644 --- a/test/ui/fixtures/trace-custom/trace/runner.ts +++ b/test/ui/fixtures/trace-custom/trace/runner.ts @@ -1,26 +1,8 @@ -import type { Page } from 'playwright' -import type { RunnerTestCase, TestContext, TestTryOptions } from 'vitest' +import type { RunnerTestCase, TestTryOptions } from 'vitest' import { TestRunner } from 'vitest' -import { finishTraceAttempt, getTraceAttempt, startTraceAttempt } from './attempt' -import { createTraceRecorder } from './recorder' - -type TraceFactory = (page: Page) => ReturnType - -declare module 'vitest' { - interface TestContext { - trace: TraceFactory - } -} +import { finishTraceAttempt, startTraceAttempt } from './attempt' export default class TraceRunner extends TestRunner { - override extendTaskContext(context: TestContext): TestContext { - super.extendTaskContext(context) - context.trace = async (page) => { - return createTraceRecorder(page, context.task, getTraceAttempt(context.task)) - } - return context - } - override onBeforeTryTask(test: RunnerTestCase, options: TestTryOptions): void { super.onBeforeTryTask(test, options) startTraceAttempt(test, options) diff --git a/test/ui/fixtures/trace-custom/trace/test.ts b/test/ui/fixtures/trace-custom/trace/test.ts new file mode 100644 index 000000000000..5a9927a5fae1 --- /dev/null +++ b/test/ui/fixtures/trace-custom/trace/test.ts @@ -0,0 +1,21 @@ +import { chromium } from 'playwright' +import { test as base } from 'vitest' +import { getTraceAttempt } from './attempt' +import { createTraceRecorder } from './recorder' + +export const test = base + .extend('browser', { scope: 'worker' }, async ({}, { onCleanup }) => { + const browser = await chromium.launch() + onCleanup(() => browser.close()) + return browser + }) + .extend('page', async ({ browser }, { onCleanup }) => { + const page = await browser.newPage() + onCleanup(() => page.close()) + return page + }) + .extend('trace', { auto: true }, async ({ page, task }, { onCleanup }) => { + const trace = await createTraceRecorder(page, task, getTraceAttempt(task)) + onCleanup(() => trace.finish()) + return trace + }) diff --git a/test/ui/test/trace-custom.spec.ts b/test/ui/test/trace-custom.spec.ts index f16cba410973..a45a7aa841a5 100644 --- a/test/ui/test/trace-custom.spec.ts +++ b/test/ui/test/trace-custom.spec.ts @@ -83,7 +83,7 @@ async function testCustomTrace(page: Page, baseURL: string) { await traceSteps.nth(0).click() const editor = page.getByTestId('editor') const activeLine = editor.locator('.CodeMirror-activeline') - await expect(activeLine).toHaveText(/await recorder\.snapshot\('before action'\)/) + await expect(activeLine).toHaveText(/await trace\.snapshot\('before action'\)/) const traceEditorMarkers = editor.getByTestId('trace-editor-marker') await expect(traceEditorMarkers).toHaveCount(3) @@ -93,7 +93,7 @@ async function testCustomTrace(page: Page, baseURL: string) { await expect(snapshotMarker).toHaveAttribute('aria-current', 'step') await traceSteps.nth(1).click() - await expect(activeLine).toHaveText(/const result = await recorder\.mark\('action'/) + await expect(activeLine).toHaveText(/const result = await trace\.mark\('action'/) await expect(actionMarker).toHaveAttribute('aria-current', 'step') await expect(traceFrame.getByRole('button', { name: 'After action' })).toBeVisible() From ddad2fba29c5c9234fc0089def0d6814ed88e888 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Date: Tue, 25 Aug 2026 13:41:36 +0900 Subject: [PATCH 18/25] refactor(ui): omit resolved trace stack Co-authored-by: OpenCode (gpt-5.6-sol) --- test/ui/fixtures/trace-custom/trace/recorder.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/test/ui/fixtures/trace-custom/trace/recorder.ts b/test/ui/fixtures/trace-custom/trace/recorder.ts index 30de3316ea30..2c4004a2a6d0 100644 --- a/test/ui/fixtures/trace-custom/trace/recorder.ts +++ b/test/ui/fixtures/trace-custom/trace/recorder.ts @@ -89,7 +89,6 @@ export async function createTraceRecorder( snapshot, ...(options.range ? { range: options.range } : {}), ...(options.status ? { status: options.status } : {}), - ...(options.stack ? { stack: options.stack } : {}), ...(location ? { location } : {}), }], }, From 41b7c09030bfac6f7bc93b8c45d680443330fe19 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Date: Tue, 25 Aug 2026 13:42:45 +0900 Subject: [PATCH 19/25] nit --- test/ui/fixtures/trace-custom/trace/recorder.ts | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/test/ui/fixtures/trace-custom/trace/recorder.ts b/test/ui/fixtures/trace-custom/trace/recorder.ts index 2c4004a2a6d0..b4a19552eeb1 100644 --- a/test/ui/fixtures/trace-custom/trace/recorder.ts +++ b/test/ui/fixtures/trace-custom/trace/recorder.ts @@ -1,6 +1,7 @@ import type { Page } from 'playwright' import type { TestContext } from 'vitest' import type { MarkOptions } from 'vitest/browser' +// @ts-ignore import { parseStacktrace } from '@vitest/utils/source-map' import { createRequire } from 'node:module' import { recordArtifact, vi } from 'vitest' @@ -95,7 +96,6 @@ export async function createTraceRecorder( }) } - const snapshot = vi.defineHelper(recordSnapshot) const finish = async (): Promise => { const status = task.result?.state const stack = status === 'fail' ? task.result?.errors?.[0].stack : undefined @@ -108,7 +108,8 @@ export async function createTraceRecorder( ...(stack ? { stack } : location ? { location } : {}), }) } - const mark = vi.defineHelper(async ( + + const mark: TraceRecorder['mark'] = async ( name: string, bodyOrOptions?: MarkOptions | (() => T | Promise), options?: MarkOptions, @@ -140,11 +141,11 @@ export async function createTraceRecorder( status, }) } - }) as TraceRecorder['mark'] + } return { finish, - snapshot, - mark, + snapshot: vi.defineHelper(recordSnapshot), + mark: vi.defineHelper(mark) } } From 663396e7914f519abd9d46b6d2d85c50d5c81fdd Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Date: Tue, 25 Aug 2026 13:49:06 +0900 Subject: [PATCH 20/25] perf(ui): load custom trace snapshot lazily Co-authored-by: OpenCode (gpt-5.6-sol) --- test/ui/fixtures/trace-custom/trace/recorder.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/test/ui/fixtures/trace-custom/trace/recorder.ts b/test/ui/fixtures/trace-custom/trace/recorder.ts index b4a19552eeb1..b58f0d488fc7 100644 --- a/test/ui/fixtures/trace-custom/trace/recorder.ts +++ b/test/ui/fixtures/trace-custom/trace/recorder.ts @@ -45,10 +45,11 @@ export async function createTraceRecorder( task: TestContext['task'], attempt: TraceAttempt, ): Promise { - await page.addScriptTag({ path: rrwebSnapshotPath }) + let snapshotReady: Promise | undefined async function recordSnapshot(name: string, options: SnapshotEntryOptions = {}): Promise { const startTime = performance.now() - attempt.startTime + await (snapshotReady ??= page.addScriptTag({ path: rrwebSnapshotPath })) const stackLocation = options.stack ? parseStacktrace(options.stack)[0] : undefined const location = options.location ?? (stackLocation ? { From 398d18ede74436da776459e95d490f8b0f1861c7 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Date: Tue, 25 Aug 2026 15:05:29 +0900 Subject: [PATCH 21/25] test(ui): prototype traced playwright actions Co-authored-by: OpenCode (gpt-5.6-sol) --- test/ui/fixtures/trace-custom/basic.test.ts | 18 +- test/ui/fixtures/trace-custom/trace/active.ts | 20 ++ test/ui/fixtures/trace-custom/trace/expect.ts | 51 ++++ .../fixtures/trace-custom/trace/recorder.ts | 260 ++++++++++++++++-- test/ui/fixtures/trace-custom/trace/test.ts | 9 +- test/ui/test/trace-custom.spec.ts | 41 ++- 6 files changed, 349 insertions(+), 50 deletions(-) create mode 100644 test/ui/fixtures/trace-custom/trace/active.ts create mode 100644 test/ui/fixtures/trace-custom/trace/expect.ts diff --git a/test/ui/fixtures/trace-custom/basic.test.ts b/test/ui/fixtures/trace-custom/basic.test.ts index f84d1d3664e2..e582f28a3625 100644 --- a/test/ui/fixtures/trace-custom/basic.test.ts +++ b/test/ui/fixtures/trace-custom/basic.test.ts @@ -1,16 +1,12 @@ -import { expect } from 'vitest' -import { test } from './trace/test' +import { expect, test } from './trace/test' -test('custom trace', async ({ page, trace }) => { +test('custom trace', async ({ page }) => { await page.setContent('
') - await trace.snapshot('before action') + await page.locator('main').evaluate((element) => { + element.innerHTML = '' + }) - const result = await trace.mark('action', async () => { - await page.locator('main').evaluate((element) => { - element.innerHTML = '' - }) - return 'action result' - }, { kind: 'action' }) - expect(result).toBe('action result') + await expect(page.getByRole('button', { name: 'After action' })).toBeVisible() + await expect(page.getByRole('button', { name: 'Missing' }).click({ timeout: 10 })).rejects.toThrow() }) diff --git a/test/ui/fixtures/trace-custom/trace/active.ts b/test/ui/fixtures/trace-custom/trace/active.ts new file mode 100644 index 000000000000..0425fc6ad4d8 --- /dev/null +++ b/test/ui/fixtures/trace-custom/trace/active.ts @@ -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 +} diff --git a/test/ui/fixtures/trace-custom/trace/expect.ts b/test/ui/fixtures/trace-custom/trace/expect.ts new file mode 100644 index 000000000000..2b78a0c98b95 --- /dev/null +++ b/test/ui/fixtures/trace-custom/trace/expect.ts @@ -0,0 +1,51 @@ +import type { Locator } from 'playwright' +import type { Assertion, ExpectStatic } from 'vitest' +// @ts-ignore +import { parseStacktrace } from '@vitest/utils/source-map' +import { expect as baseExpect } from 'vitest' +import { getActiveTraceRecorder } from './active' + +interface LocatorAssertion extends Assertion, Locator> { + toBeVisible: (options?: { timeout?: number }) => Promise +} + +type TraceExpect = ExpectStatic & { + (actual: Locator, message?: string): LocatorAssertion +} + +export const expect = new Proxy(baseExpect, { + apply(target, thisArg, argumentsList) { + const [actual] = argumentsList + const assertion = Reflect.apply(target, thisArg, argumentsList) + if (!isLocator(actual)) { + return assertion + } + + return new Proxy(assertion, { + get(target, key, receiver) { + if (key !== 'toBeVisible') { + return Reflect.get(target, key, receiver) + } + + return async (options?: { timeout?: number }) => { + const frame = parseStacktrace(new Error().stack ?? '').find(({ file }) => !file.includes('/trace/')) + const location = frame + ? { file: frame.file, line: frame.line, column: frame.column } + : undefined + await getActiveTraceRecorder().assert( + 'expect.toBeVisible', + () => baseExpect.poll(() => actual.isVisible(), options).toBe(true), + { location }, + ) + } + }, + }) + }, +}) as TraceExpect + +function isLocator(value: unknown): value is Locator { + return !!value + && typeof value === 'object' + && typeof (value as Locator).isVisible === 'function' + && typeof (value as Locator).locator === 'function' +} diff --git a/test/ui/fixtures/trace-custom/trace/recorder.ts b/test/ui/fixtures/trace-custom/trace/recorder.ts index b58f0d488fc7..a8757f1dfec5 100644 --- a/test/ui/fixtures/trace-custom/trace/recorder.ts +++ b/test/ui/fixtures/trace-custom/trace/recorder.ts @@ -3,6 +3,7 @@ import type { TestContext } from 'vitest' import type { MarkOptions } from 'vitest/browser' // @ts-ignore import { parseStacktrace } from '@vitest/utils/source-map' +import { AsyncLocalStorage } from 'node:async_hooks' import { createRequire } from 'node:module' import { recordArtifact, vi } from 'vitest' @@ -31,7 +32,35 @@ interface SnapshotEntryOptions extends SnapshotOptions { } } +interface ApiCallData { + apiName?: string + error?: Error + frames: Array<{ + file: string + line: number + column: number + }> +} + +interface ApiCallChannel { + method: string + params?: Record + type: string +} + +interface ApiCallState { + apiName?: string + data?: ApiCallData + location?: SnapshotOptions['location'] +} + +interface CapturedSnapshot { + snapshot: unknown + startTime: number +} + export interface TraceRecorder { + assert: (name: string, body: () => T | Promise, options?: SnapshotOptions) => Promise finish: () => Promise snapshot: (name: string, options?: SnapshotOptions) => Promise mark: { @@ -45,11 +74,58 @@ export async function createTraceRecorder( task: TestContext['task'], attempt: TraceAttempt, ): Promise { - let snapshotReady: Promise | undefined + const apiCallStorage = new AsyncLocalStorage<'internal' | ApiCallState>() + const instrumentation = (page as any)._instrumentation + const apiCallOwner = findApiCallOwner(page) + const originalWrapApiCall = apiCallOwner._wrapApiCall + let internalCallDepth = 0 + let finished = false - async function recordSnapshot(name: string, options: SnapshotEntryOptions = {}): Promise { + const runInternal = async (body: () => T | Promise): Promise => { + internalCallDepth += 1 + try { + return await apiCallStorage.run('internal', body) + } + finally { + internalCallDepth -= 1 + } + } + + async function captureSnapshot(): Promise { const startTime = performance.now() - attempt.startTime - await (snapshotReady ??= page.addScriptTag({ path: rrwebSnapshotPath })) + return runInternal(async () => { + const snapshotReady = await page.evaluate(() => !!(globalThis as any).rrwebSnapshot) + if (!snapshotReady) { + await page.addScriptTag({ path: rrwebSnapshotPath }) + } + const snapshot = await page.evaluate(() => { + const { snapshot } = (globalThis as any).rrwebSnapshot + const serialized = snapshot(document) + if (!serialized) { + throw new Error('Failed to serialize document') + } + return { + serialized, + viewport: { + width: globalThis.innerWidth, + height: globalThis.innerHeight, + }, + scroll: { + x: globalThis.scrollX, + y: globalThis.scrollY, + }, + pseudoClassIds: {}, + } + }) + return { snapshot, startTime } + }) + } + + async function recordCapturedSnapshot( + name: string, + captured: CapturedSnapshot, + options: SnapshotEntryOptions = {}, + ): Promise { const stackLocation = options.stack ? parseStacktrace(options.stack)[0] : undefined const location = options.location ?? (stackLocation ? { @@ -58,26 +134,6 @@ export async function createTraceRecorder( column: stackLocation.column, } : undefined) - const snapshot = await page.evaluate(() => { - const { snapshot } = (globalThis as any).rrwebSnapshot - const serialized = snapshot(document) - if (!serialized) { - throw new Error('Failed to serialize document') - } - return { - serialized, - viewport: { - width: globalThis.innerWidth, - height: globalThis.innerHeight, - }, - scroll: { - x: globalThis.scrollX, - y: globalThis.scrollY, - }, - pseudoClassIds: {}, - } - }) - await recordArtifact(task, { type: 'internal:browserTrace', data: { @@ -87,8 +143,8 @@ export async function createTraceRecorder( entries: [{ name, kind: options.kind ?? 'mark', - startTime, - snapshot, + startTime: captured.startTime, + snapshot: captured.snapshot, ...(options.range ? { range: options.range } : {}), ...(options.status ? { status: options.status } : {}), ...(location ? { location } : {}), @@ -97,7 +153,82 @@ export async function createTraceRecorder( }) } + async function recordSnapshot(name: string, options: SnapshotEntryOptions = {}): Promise { + await recordCapturedSnapshot(name, await captureSnapshot(), options) + } + + const apiCallListener = { + onApiCallBegin(data: ApiCallData, channel: ApiCallChannel) { + const state = apiCallStorage.getStore() + if (state && state !== 'internal') { + state.data = data + state.apiName ??= getApiCallName(channel) + } + }, + } + + instrumentation.addListener(apiCallListener) + // Spike only: Playwright does not expose an awaited API-call instrumentation hook. + apiCallOwner._wrapApiCall = async function ( + body: (zone: unknown) => Promise, + options?: { internal?: boolean; title?: string }, + ): Promise { + if (options?.internal || internalCallDepth || apiCallStorage.getStore()) { + return originalWrapApiCall.call(this, body, options) + } + + const stack = new Error().stack + const apiName = inferApiName(stack) + const state: ApiCallState = { + apiName, + location: findUserLocation(stack), + } + return apiCallStorage.run(state, async () => { + const startSnapshot = await captureSnapshot() + let status: 'pass' | 'fail' = 'pass' + try { + return await originalWrapApiCall.call( + this, + body, + apiName ? { ...options, title: apiName } : options, + ) + } + catch (error) { + status = 'fail' + throw error + } + finally { + const recordedName = state.apiName ?? state.data?.apiName + if (recordedName) { + const endSnapshot = await captureSnapshot() + const rangeId = Math.random().toString(36).slice(2) + const frame = state.data?.frames[0] + const location = state.location ?? (frame + ? { file: frame.file, line: frame.line, column: frame.column } + : undefined) + await recordCapturedSnapshot(recordedName, startSnapshot, { + kind: 'action', + location, + range: { id: rangeId, phase: 'start' }, + }) + await recordCapturedSnapshot(recordedName, endSnapshot, { + kind: 'action', + location, + range: { id: rangeId, phase: 'end' }, + status, + }) + } + } + }) + } + const finish = async (): Promise => { + if (finished) { + return + } + finished = true + apiCallOwner._wrapApiCall = originalWrapApiCall + instrumentation.removeListener(apiCallListener) const status = task.result?.state const stack = status === 'fail' ? task.result?.errors?.[0].stack : undefined const location = task.location @@ -110,6 +241,36 @@ export async function createTraceRecorder( }) } + const assert = async ( + name: string, + body: () => T | Promise, + options?: SnapshotOptions, + ): Promise => { + const rangeId = Math.random().toString(36).slice(2) + await recordSnapshot(name, { + ...options, + kind: 'expect', + range: { id: rangeId, phase: 'start' }, + }) + + let status: 'pass' | 'fail' = 'pass' + try { + return await runInternal(body) + } + catch (error) { + status = 'fail' + throw error + } + finally { + await recordSnapshot(name, { + ...options, + kind: 'expect', + range: { id: rangeId, phase: 'end' }, + status, + }) + } + } + const mark: TraceRecorder['mark'] = async ( name: string, bodyOrOptions?: MarkOptions | (() => T | Promise), @@ -145,8 +306,55 @@ export async function createTraceRecorder( } return { + assert, finish, snapshot: vi.defineHelper(recordSnapshot), - mark: vi.defineHelper(mark) + mark: vi.defineHelper(mark), + } +} + +function findApiCallOwner(page: Page): any { + let prototype = page as any + while (prototype && !Object.hasOwn(prototype, '_wrapApiCall')) { + prototype = Object.getPrototypeOf(prototype) + } + if (!prototype) { + throw new Error('Playwright ChannelOwner._wrapApiCall was not found') + } + return prototype +} + +function inferApiName(stack: string | undefined): string | undefined { + let pageMethod: string | undefined + for (const line of stack?.split('\n') ?? []) { + const match = line.match(/at _?(Locator|Page|Frame)\.([^ ]+)/) + if (!match || match[2].startsWith('_') || match[2].includes('._')) { + continue + } + if (match[1] === 'Locator') { + return `locator.${match[2]}` + } + pageMethod ??= `page.${match[2]}` + } + return pageMethod +} + +function findUserLocation(stack: string | undefined): SnapshotOptions['location'] { + const frame = parseStacktrace(stack ?? '').find(({ file }) => { + return !file.includes('/node_modules/') && !file.includes('/trace/') + }) + return frame + ? { file: frame.file, line: frame.line, column: frame.column } + : undefined +} + +function getApiCallName(channel: ApiCallChannel): string { + const method = channel.method === 'evaluateExpression' ? 'evaluate' : channel.method + if (typeof channel.params?.selector === 'string') { + return `locator.${method}` + } + if (channel.type === 'Frame') { + return `page.${method}` } + return `${channel.type.charAt(0).toLowerCase()}${channel.type.slice(1)}.${method}` } diff --git a/test/ui/fixtures/trace-custom/trace/test.ts b/test/ui/fixtures/trace-custom/trace/test.ts index 5a9927a5fae1..97424b837d23 100644 --- a/test/ui/fixtures/trace-custom/trace/test.ts +++ b/test/ui/fixtures/trace-custom/trace/test.ts @@ -1,8 +1,11 @@ import { chromium } from 'playwright' import { test as base } from 'vitest' +import { clearActiveTraceRecorder, setActiveTraceRecorder } from './active' import { getTraceAttempt } from './attempt' import { createTraceRecorder } from './recorder' +export { expect } from './expect' + export const test = base .extend('browser', { scope: 'worker' }, async ({}, { onCleanup }) => { const browser = await chromium.launch() @@ -16,6 +19,10 @@ export const test = base }) .extend('trace', { auto: true }, async ({ page, task }, { onCleanup }) => { const trace = await createTraceRecorder(page, task, getTraceAttempt(task)) - onCleanup(() => trace.finish()) + setActiveTraceRecorder(trace) + onCleanup(async () => { + clearActiveTraceRecorder(trace) + await trace.finish() + }) return trace }) diff --git a/test/ui/test/trace-custom.spec.ts b/test/ui/test/trace-custom.spec.ts index a45a7aa841a5..867675d1f09b 100644 --- a/test/ui/test/trace-custom.spec.ts +++ b/test/ui/test/trace-custom.spec.ts @@ -70,12 +70,18 @@ async function testCustomTrace(page: Page, baseURL: string) { const traceSteps = traceView.getByTestId('trace-step') await expect(traceView.getByTestId('trace-step-name')).toHaveText([ - 'before action', - 'action', + 'page.setContent', + 'locator.evaluate', + 'expect.toBeVisible', + 'locator.click', 'test finished', ]) + await expect(traceSteps.nth(0)).toHaveAttribute('data-test-range', 'end') await expect(traceSteps.nth(1)).toHaveAttribute('data-test-range', 'end') - await expect(traceSteps.nth(1).locator('.text-blue-500')).toBeVisible() + await expect(traceSteps.nth(2)).toHaveAttribute('data-test-range', 'end') + await expect(traceSteps.nth(3)).toHaveAttribute('data-test-range', 'end') + await expect(traceSteps.nth(0).locator('.text-blue-500')).toBeVisible() + await expect(traceSteps.nth(3)).toHaveClass(/text-red-600/) const traceFrame = traceView.frameLocator('iframe') await expect(traceFrame.getByRole('button', { name: 'Before action' })).toBeVisible() @@ -83,21 +89,31 @@ async function testCustomTrace(page: Page, baseURL: string) { await traceSteps.nth(0).click() const editor = page.getByTestId('editor') const activeLine = editor.locator('.CodeMirror-activeline') - await expect(activeLine).toHaveText(/await trace\.snapshot\('before action'\)/) + await expect(activeLine).toHaveText(/await page\.setContent/) const traceEditorMarkers = editor.getByTestId('trace-editor-marker') - await expect(traceEditorMarkers).toHaveCount(3) - const snapshotMarker = traceEditorMarkers.and(page.locator('[aria-label="Select trace step: before action"]')) - const actionMarker = traceEditorMarkers.and(page.locator('[aria-label="Select trace step: action"]')) + await expect(traceEditorMarkers).toHaveCount(5) + const setContentMarker = traceEditorMarkers.and(page.locator('[aria-label="Select trace step: page.setContent"]')) + const evaluateMarker = traceEditorMarkers.and(page.locator('[aria-label="Select trace step: locator.evaluate"]')) + const assertionMarker = traceEditorMarkers.and(page.locator('[aria-label="Select trace step: expect.toBeVisible"]')) + const failedActionMarker = traceEditorMarkers.and(page.locator('[aria-label="Select trace step: locator.click"]')) const lifecycleMarker = traceEditorMarkers.and(page.locator('[aria-label="Select trace step: vitest:onAfterRetryTask"]')) - await expect(snapshotMarker).toHaveAttribute('aria-current', 'step') + await expect(setContentMarker).toHaveAttribute('aria-current', 'step') await traceSteps.nth(1).click() - await expect(activeLine).toHaveText(/const result = await trace\.mark\('action'/) - await expect(actionMarker).toHaveAttribute('aria-current', 'step') + await expect(activeLine).toHaveText(/await page\.locator\('main'\)\.evaluate/) + await expect(evaluateMarker).toHaveAttribute('aria-current', 'step') await expect(traceFrame.getByRole('button', { name: 'After action' })).toBeVisible() await traceSteps.nth(2).click() + await expect(activeLine).toHaveText(/await expect\(page\.getByRole/) + await expect(assertionMarker).toHaveAttribute('aria-current', 'step') + + await traceSteps.nth(3).click() + await expect(activeLine).toHaveText(/name: 'Missing'/) + await expect(failedActionMarker).toHaveAttribute('aria-current', 'step') + + await traceSteps.nth(4).click() await expect(activeLine).toHaveText(/test\('custom trace'/) await expect(lifecycleMarker).toHaveAttribute('aria-current', 'step') @@ -114,10 +130,11 @@ async function testCustomTrace(page: Page, baseURL: string) { for (let index = 0; index < 4; index++) { await traceOpenButtons.nth(index).click() await expect(traceView.getByTestId('trace-step-name')).toHaveText([ + 'page.setContent', 'attempt', 'test finished', ]) - const lifecycleStep = traceView.getByTestId('trace-step').nth(1) + const lifecycleStep = traceView.getByTestId('trace-step').nth(2) if (index % 2 === 0) { await expect(lifecycleStep).toHaveClass(/text-red-600/) } @@ -127,6 +144,6 @@ async function testCustomTrace(page: Page, baseURL: string) { } await traceOpenButtons.nth(0).click() - await traceView.getByTestId('trace-step').nth(1).click() + await traceView.getByTestId('trace-step').nth(2).click() await expect(page.getByTestId('editor').locator('.CodeMirror-activeline')).toHaveText(/throw new Error\('Retry this attempt'\)/) } From 259540f93f4d78c86c4d8429fbe1f3d240a9dcc7 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Date: Tue, 25 Aug 2026 15:16:51 +0900 Subject: [PATCH 22/25] refactor(ui): extend custom trace expect Co-authored-by: OpenCode (gpt-5.6-sol) --- test/ui/fixtures/trace-custom/basic.test.ts | 3 +- test/ui/fixtures/trace-custom/trace/expect.ts | 65 +++++++++---------- test/ui/fixtures/trace-custom/trace/test.ts | 3 +- 3 files changed, 34 insertions(+), 37 deletions(-) diff --git a/test/ui/fixtures/trace-custom/basic.test.ts b/test/ui/fixtures/trace-custom/basic.test.ts index e582f28a3625..dfd6bef55cdd 100644 --- a/test/ui/fixtures/trace-custom/basic.test.ts +++ b/test/ui/fixtures/trace-custom/basic.test.ts @@ -1,4 +1,5 @@ -import { expect, test } from './trace/test' +import { expect } from 'vitest' +import { test } from './trace/test' test('custom trace', async ({ page }) => { await page.setContent('
') diff --git a/test/ui/fixtures/trace-custom/trace/expect.ts b/test/ui/fixtures/trace-custom/trace/expect.ts index 2b78a0c98b95..b558f743175f 100644 --- a/test/ui/fixtures/trace-custom/trace/expect.ts +++ b/test/ui/fixtures/trace-custom/trace/expect.ts @@ -1,47 +1,44 @@ import type { Locator } from 'playwright' -import type { Assertion, ExpectStatic } from 'vitest' // @ts-ignore import { parseStacktrace } from '@vitest/utils/source-map' -import { expect as baseExpect } from 'vitest' +import { expect } from 'vitest' import { getActiveTraceRecorder } from './active' -interface LocatorAssertion extends Assertion, Locator> { - toBeVisible: (options?: { timeout?: number }) => Promise -} - -type TraceExpect = ExpectStatic & { - (actual: Locator, message?: string): LocatorAssertion -} - -export const expect = new Proxy(baseExpect, { - apply(target, thisArg, argumentsList) { - const [actual] = argumentsList - const assertion = Reflect.apply(target, thisArg, argumentsList) +expect.extend({ + async toBeVisible(actual: unknown, options?: { timeout?: number }) { if (!isLocator(actual)) { - return assertion + throw new TypeError('toBeVisible expects a Playwright Locator') } - return new Proxy(assertion, { - get(target, key, receiver) { - if (key !== 'toBeVisible') { - return Reflect.get(target, key, receiver) - } - - return async (options?: { timeout?: number }) => { - const frame = parseStacktrace(new Error().stack ?? '').find(({ file }) => !file.includes('/trace/')) - const location = frame - ? { file: frame.file, line: frame.line, column: frame.column } - : undefined - await getActiveTraceRecorder().assert( - 'expect.toBeVisible', - () => baseExpect.poll(() => actual.isVisible(), options).toBe(true), - { location }, - ) - } - }, + 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), + } + } }, -}) as TraceExpect +}) function isLocator(value: unknown): value is Locator { return !!value diff --git a/test/ui/fixtures/trace-custom/trace/test.ts b/test/ui/fixtures/trace-custom/trace/test.ts index 97424b837d23..f22824c2fd82 100644 --- a/test/ui/fixtures/trace-custom/trace/test.ts +++ b/test/ui/fixtures/trace-custom/trace/test.ts @@ -2,10 +2,9 @@ import { chromium } from 'playwright' import { test as base } from 'vitest' import { clearActiveTraceRecorder, setActiveTraceRecorder } from './active' import { getTraceAttempt } from './attempt' +import './expect' import { createTraceRecorder } from './recorder' -export { expect } from './expect' - export const test = base .extend('browser', { scope: 'worker' }, async ({}, { onCleanup }) => { const browser = await chromium.launch() From fc27badfcadb113fd62f92ae12c189bb0e2210a8 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Date: Tue, 25 Aug 2026 15:35:06 +0900 Subject: [PATCH 23/25] test(ui): serve custom trace demo app Co-authored-by: OpenCode (gpt-5.6-sol) --- test/ui/fixtures/trace-custom/app/app.js | 11 +++++ test/ui/fixtures/trace-custom/app/index.html | 19 +++++++++ .../ui/fixtures/trace-custom/attempts.test.ts | 2 +- test/ui/fixtures/trace-custom/basic.test.ts | 10 ++--- test/ui/fixtures/trace-custom/global-setup.ts | 41 +++++++++++++++++++ test/ui/fixtures/trace-custom/trace/test.ts | 3 +- .../ui/fixtures/trace-custom/vitest.config.ts | 1 + test/ui/test/trace-custom.spec.ts | 1 + 8 files changed, 79 insertions(+), 9 deletions(-) create mode 100644 test/ui/fixtures/trace-custom/app/app.js create mode 100644 test/ui/fixtures/trace-custom/app/index.html create mode 100644 test/ui/fixtures/trace-custom/global-setup.ts diff --git a/test/ui/fixtures/trace-custom/app/app.js b/test/ui/fixtures/trace-custom/app/app.js new file mode 100644 index 000000000000..ba2268c3eb30 --- /dev/null +++ b/test/ui/fixtures/trace-custom/app/app.js @@ -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}` +}) diff --git a/test/ui/fixtures/trace-custom/app/index.html b/test/ui/fixtures/trace-custom/app/index.html new file mode 100644 index 000000000000..84e8ebab1a65 --- /dev/null +++ b/test/ui/fixtures/trace-custom/app/index.html @@ -0,0 +1,19 @@ + + + + + + Trace demo + + +
+ + + Attempt 0 +
+ + + diff --git a/test/ui/fixtures/trace-custom/attempts.test.ts b/test/ui/fixtures/trace-custom/attempts.test.ts index 2396ab481b26..1e7b2d736ef7 100644 --- a/test/ui/fixtures/trace-custom/attempts.test.ts +++ b/test/ui/fixtures/trace-custom/attempts.test.ts @@ -5,7 +5,7 @@ let attemptIndex = 0 test('custom trace attempts', { retry: 1, repeats: 1 }, async ({ page, trace }) => { const currentAttempt = attemptIndex++ - await page.setContent(`
Attempt ${currentAttempt}
`) + await page.getByLabel('Attempt').fill(String(currentAttempt)) await trace.snapshot('attempt') if (currentAttempt % 2 === 0) { diff --git a/test/ui/fixtures/trace-custom/basic.test.ts b/test/ui/fixtures/trace-custom/basic.test.ts index f84d1d3664e2..6ccc16570759 100644 --- a/test/ui/fixtures/trace-custom/basic.test.ts +++ b/test/ui/fixtures/trace-custom/basic.test.ts @@ -2,15 +2,11 @@ import { expect } from 'vitest' import { test } from './trace/test' test('custom trace', async ({ page, trace }) => { - await page.setContent('
') - await trace.snapshot('before action') const result = await trace.mark('action', async () => { - await page.locator('main').evaluate((element) => { - element.innerHTML = '' - }) - return 'action result' + await page.getByRole('button', { name: 'Before action' }).click() + return page.getByRole('button').textContent() }, { kind: 'action' }) - expect(result).toBe('action result') + expect(result).toBe('After action') }) diff --git a/test/ui/fixtures/trace-custom/global-setup.ts b/test/ui/fixtures/trace-custom/global-setup.ts new file mode 100644 index 000000000000..76fa4785f8ed --- /dev/null +++ b/test/ui/fixtures/trace-custom/global-setup.ts @@ -0,0 +1,41 @@ +import type { Server } from 'node:http' +import type { AddressInfo } from 'node:net' +import type { TestProject } from 'vitest/node' +import { readFile } from 'node:fs/promises' +import { createServer } from 'node:http' + +declare module 'vitest' { + interface ProvidedContext { + traceAppUrl: string + } +} + +let server: Server + +export async function setup({ provide }: TestProject): Promise { + const html = await readFile(new URL('./app/index.html', import.meta.url)) + const script = await readFile(new URL('./app/app.js', import.meta.url)) + + server = createServer((request, response) => { + if (request.url === '/app.js') { + response.writeHead(200, { 'Content-Type': 'text/javascript' }) + response.end(script) + return + } + response.writeHead(200, { 'Content-Type': 'text/html' }) + response.end(html) + }) + await new Promise((resolve, reject) => { + server.once('error', reject) + server.listen(0, '127.0.0.1', resolve) + }) + + const address = server.address() as AddressInfo + provide('traceAppUrl', `http://127.0.0.1:${address.port}`) +} + +export async function teardown(): Promise { + await new Promise((resolve, reject) => { + server.close(error => error ? reject(error) : resolve()) + }) +} diff --git a/test/ui/fixtures/trace-custom/trace/test.ts b/test/ui/fixtures/trace-custom/trace/test.ts index 5a9927a5fae1..ea1457a253a2 100644 --- a/test/ui/fixtures/trace-custom/trace/test.ts +++ b/test/ui/fixtures/trace-custom/trace/test.ts @@ -1,5 +1,5 @@ import { chromium } from 'playwright' -import { test as base } from 'vitest' +import { inject, test as base } from 'vitest' import { getTraceAttempt } from './attempt' import { createTraceRecorder } from './recorder' @@ -12,6 +12,7 @@ export const test = base .extend('page', async ({ browser }, { onCleanup }) => { const page = await browser.newPage() onCleanup(() => page.close()) + await page.goto(inject('traceAppUrl')) return page }) .extend('trace', { auto: true }, async ({ page, task }, { onCleanup }) => { diff --git a/test/ui/fixtures/trace-custom/vitest.config.ts b/test/ui/fixtures/trace-custom/vitest.config.ts index a419eded8694..bf77368146ae 100644 --- a/test/ui/fixtures/trace-custom/vitest.config.ts +++ b/test/ui/fixtures/trace-custom/vitest.config.ts @@ -2,6 +2,7 @@ import { defineConfig } from 'vitest/config' export default defineConfig({ test: { + globalSetup: './global-setup.ts', runner: './trace/runner.ts', ui: true, }, diff --git a/test/ui/test/trace-custom.spec.ts b/test/ui/test/trace-custom.spec.ts index a45a7aa841a5..febcd003afca 100644 --- a/test/ui/test/trace-custom.spec.ts +++ b/test/ui/test/trace-custom.spec.ts @@ -117,6 +117,7 @@ async function testCustomTrace(page: Page, baseURL: string) { 'attempt', 'test finished', ]) + await expect(traceFrame.getByText(`Attempt ${index}`)).toBeVisible() const lifecycleStep = traceView.getByTestId('trace-step').nth(1) if (index % 2 === 0) { await expect(lifecycleStep).toHaveClass(/text-red-600/) From 16982b0a1833d18992ec011b18382fa9adc853fa Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Date: Tue, 25 Aug 2026 18:31:53 +0900 Subject: [PATCH 24/25] test(ui): configure trace demo base url Co-authored-by: OpenCode (gpt-5.6-sol) --- test/ui/fixtures/trace-custom/attempts.test.ts | 1 + test/ui/fixtures/trace-custom/basic.test.ts | 1 + test/ui/fixtures/trace-custom/global-setup.ts | 4 ++-- test/ui/fixtures/trace-custom/trace/test.ts | 6 +++--- 4 files changed, 7 insertions(+), 5 deletions(-) diff --git a/test/ui/fixtures/trace-custom/attempts.test.ts b/test/ui/fixtures/trace-custom/attempts.test.ts index 1e7b2d736ef7..c8ae58cdbdf4 100644 --- a/test/ui/fixtures/trace-custom/attempts.test.ts +++ b/test/ui/fixtures/trace-custom/attempts.test.ts @@ -5,6 +5,7 @@ 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') diff --git a/test/ui/fixtures/trace-custom/basic.test.ts b/test/ui/fixtures/trace-custom/basic.test.ts index 6ccc16570759..c4fdeb4f568f 100644 --- a/test/ui/fixtures/trace-custom/basic.test.ts +++ b/test/ui/fixtures/trace-custom/basic.test.ts @@ -2,6 +2,7 @@ import { expect } from 'vitest' import { test } from './trace/test' test('custom trace', async ({ page, trace }) => { + await page.goto('/') await trace.snapshot('before action') const result = await trace.mark('action', async () => { diff --git a/test/ui/fixtures/trace-custom/global-setup.ts b/test/ui/fixtures/trace-custom/global-setup.ts index 76fa4785f8ed..605110d3d4e0 100644 --- a/test/ui/fixtures/trace-custom/global-setup.ts +++ b/test/ui/fixtures/trace-custom/global-setup.ts @@ -6,7 +6,7 @@ import { createServer } from 'node:http' declare module 'vitest' { interface ProvidedContext { - traceAppUrl: string + baseURL: string } } @@ -31,7 +31,7 @@ export async function setup({ provide }: TestProject): Promise { }) const address = server.address() as AddressInfo - provide('traceAppUrl', `http://127.0.0.1:${address.port}`) + provide('baseURL', `http://127.0.0.1:${address.port}`) } export async function teardown(): Promise { diff --git a/test/ui/fixtures/trace-custom/trace/test.ts b/test/ui/fixtures/trace-custom/trace/test.ts index ea1457a253a2..06ffd476121c 100644 --- a/test/ui/fixtures/trace-custom/trace/test.ts +++ b/test/ui/fixtures/trace-custom/trace/test.ts @@ -4,15 +4,15 @@ import { getTraceAttempt } from './attempt' import { createTraceRecorder } from './recorder' export const test = base + .extend('baseURL', () => inject('baseURL')) .extend('browser', { scope: 'worker' }, async ({}, { onCleanup }) => { const browser = await chromium.launch() onCleanup(() => browser.close()) return browser }) - .extend('page', async ({ browser }, { onCleanup }) => { - const page = await browser.newPage() + .extend('page', async ({ baseURL, browser }, { onCleanup }) => { + const page = await browser.newPage({ baseURL }) onCleanup(() => page.close()) - await page.goto(inject('traceAppUrl')) return page }) .extend('trace', { auto: true }, async ({ page, task }, { onCleanup }) => { From d7f7df750948a9b7dc33caa40b4f5e02c4b8dd5d Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Date: Tue, 25 Aug 2026 18:34:21 +0900 Subject: [PATCH 25/25] test(ui): serve trace demo with vite Co-authored-by: OpenCode (gpt-5.6-sol) --- test/ui/fixtures/trace-custom/global-setup.ts | 44 +++++++------------ 1 file changed, 16 insertions(+), 28 deletions(-) diff --git a/test/ui/fixtures/trace-custom/global-setup.ts b/test/ui/fixtures/trace-custom/global-setup.ts index 605110d3d4e0..4e7badac1e46 100644 --- a/test/ui/fixtures/trace-custom/global-setup.ts +++ b/test/ui/fixtures/trace-custom/global-setup.ts @@ -1,8 +1,7 @@ -import type { Server } from 'node:http' import type { AddressInfo } from 'node:net' import type { TestProject } from 'vitest/node' -import { readFile } from 'node:fs/promises' -import { createServer } from 'node:http' +import { fileURLToPath } from 'node:url' +import { preview } from 'vite' declare module 'vitest' { interface ProvidedContext { @@ -10,32 +9,21 @@ declare module 'vitest' { } } -let server: Server - -export async function setup({ provide }: TestProject): Promise { - const html = await readFile(new URL('./app/index.html', import.meta.url)) - const script = await readFile(new URL('./app/app.js', import.meta.url)) - - server = createServer((request, response) => { - if (request.url === '/app.js') { - response.writeHead(200, { 'Content-Type': 'text/javascript' }) - response.end(script) - return - } - response.writeHead(200, { 'Content-Type': 'text/html' }) - response.end(html) - }) - await new Promise((resolve, reject) => { - server.once('error', reject) - server.listen(0, '127.0.0.1', resolve) +export async function setup({ provide }: TestProject): Promise<() => Promise> { + 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.address() as AddressInfo + const address = server.httpServer.address() as AddressInfo provide('baseURL', `http://127.0.0.1:${address.port}`) -} - -export async function teardown(): Promise { - await new Promise((resolve, reject) => { - server.close(error => error ? reject(error) : resolve()) - }) + return () => server.close() }