From dcb65a13f9288e45a0083504f2c18f5c9ac9abb7 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Date: Thu, 3 Sep 2026 18:07:38 +0900 Subject: [PATCH] feat: open html trace reports Co-authored-by: OpenCode (gpt-5.6-sol) --- package.json | 14 ++++ packages/extension/src/api.ts | 7 +- packages/extension/src/apiProcess.ts | 13 +++- packages/extension/src/extension.ts | 8 +++ packages/extension/src/runQueue.ts | 4 ++ packages/extension/src/runner.ts | 8 ++- packages/extension/src/spawn/terminal.ts | 1 + packages/extension/src/spawn/ws.ts | 1 + packages/extension/src/traceReport.ts | 92 ++++++++++++++++++++++++ packages/extension/src/worker/index.ts | 9 +-- packages/shared/src/emitter.ts | 10 ++- packages/shared/src/index.ts | 1 + packages/worker/src/index.ts | 10 +++ test/unit/traceReport.test.ts | 60 ++++++++++++++++ 14 files changed, 228 insertions(+), 10 deletions(-) create mode 100644 packages/extension/src/traceReport.ts create mode 100644 test/unit/traceReport.test.ts diff --git a/package.json b/package.json index 85b2ea1..3d93b41 100644 --- a/package.json +++ b/package.json @@ -121,6 +121,11 @@ "command": "vitest.openOutput", "category": "Vitest" }, + { + "title": "Open Trace Report", + "command": "vitest.openTraceReport", + "category": "Vitest" + }, { "title": "Show Shell Terminal", "command": "vitest.showShellTerminal", @@ -189,6 +194,10 @@ } ], "commandPalette": [ + { + "command": "vitest.openTraceReport", + "when": "false" + }, { "command": "vitest.toggleContinuousRun", "when": "false" @@ -203,6 +212,11 @@ } ], "testing/item/context": [ + { + "command": "vitest.openTraceReport", + "when": "controllerId == 'vitest'", + "group": "vitest" + }, { "command": "vitest.updateSnapshot", "when": "controllerId == 'vitest'" diff --git a/packages/extension/src/api.ts b/packages/extension/src/api.ts index 60ce8cd..3a3ba91 100644 --- a/packages/extension/src/api.ts +++ b/packages/extension/src/api.ts @@ -193,7 +193,12 @@ async function createVitestProcessAPI( } }) const files = await meta.rpc.getFiles() - const config = new VitestProjectConfig(pkg, meta.projects, meta.workspaceSource) + const config = new VitestProjectConfig( + pkg, + meta.projects, + meta.workspaceSource, + meta.htmlReportPath, + ) const api = new VitestProcessAPI(config) return { api, files } }) diff --git a/packages/extension/src/apiProcess.ts b/packages/extension/src/apiProcess.ts index b4799fe..e8c1086 100644 --- a/packages/extension/src/apiProcess.ts +++ b/packages/extension/src/apiProcess.ts @@ -24,6 +24,7 @@ export class VitestProjectConfig { readonly pkg: VitestPackage, readonly projects: SerializedProject[], readonly workspaceSource: string | false, + readonly htmlReportPath?: string, ) { this.id = normalize(pkg.id) this.workspaceFolder = pkg.folder @@ -111,7 +112,12 @@ export class VitestProcessAPI { * a handle wrapping the existing process (without closing it). */ static forDebug(pkg: VitestPackage, meta: ResolvedMeta): VitestProcessAPI { - const config = new VitestProjectConfig(pkg, meta.projects, meta.workspaceSource) + const config = new VitestProjectConfig( + pkg, + meta.projects, + meta.workspaceSource, + meta.htmlReportPath, + ) const api = new VitestProcessAPI(config) api.currentMeta = meta return api @@ -143,6 +149,10 @@ export class VitestProcessAPI { return this.config.workspaceSource } + get htmlReportPath() { + return this.config.htmlReportPath + } + get package() { return this.config.package } @@ -323,6 +333,7 @@ export interface ResolvedMeta { rpc: VitestExtensionRPC process: ExtensionWorkerProcess workspaceSource: string | false + htmlReportPath?: string pkg: VitestPackage projects: SerializedProject[] handlers: { diff --git a/packages/extension/src/extension.ts b/packages/extension/src/extension.ts index 0de203e..dae2df4 100644 --- a/packages/extension/src/extension.ts +++ b/packages/extension/src/extension.ts @@ -19,6 +19,7 @@ import { ExtensionTerminalProcess } from './spawn/terminal' import { ExtensionState } from './state' import { TagsManager } from './tagsManager' import { TestTree } from './testTree' +import { TraceReportManager } from './traceReport' import { getTestData, TestFile } from './testTreeData' import { debounce, showVitestError } from './utils' import './polyfills' @@ -52,6 +53,7 @@ class VitestExtension { private debugManager: DebugManager private schemaProvider: TransformSchemaProvider private importsBreakdownProvider: ImportsBreakdownProvider + private traceReports = new TraceReportManager() /** @internal */ _debugDisposable: vscode.Disposable | undefined @@ -106,6 +108,7 @@ class VitestExtension { private async _defineTestProfiles(showWarning: boolean, cancelToken?: vscode.CancellationToken) { this.importsBreakdownProvider.clear() this.testTree.reset([]) + this.traceReports.clear() this.runQueues.forEach((q) => q.dispose()) this.runQueues.clear() @@ -222,6 +225,7 @@ class VitestExtension { vitest, this.diagnostic, this.importsBreakdownProvider, + this.traceReports, ) const runQueueId = `${vitest.id}:run` this.runQueues.set(runQueueId, runQueue) @@ -284,6 +288,7 @@ class VitestExtension { vitest, this.diagnostic, this.importsBreakdownProvider, + this.traceReports, ) const coverageQueueId = `${vitest.id}:coverage` this.runQueues.set(coverageQueueId, coverageQueue) @@ -342,6 +347,9 @@ class VitestExtension { vscode.commands.registerCommand('vitest.openOutput', () => { log.openOutput() }), + vscode.commands.registerCommand('vitest.openTraceReport', (testItem?: vscode.TestItem) => + this.traceReports.open(testItem), + ), vscode.commands.registerCommand('vitest.runRelatedTests', async (uri?: vscode.Uri) => { const currentUri = uri || vscode.window.activeTextEditor?.document.uri if (!currentUri) { diff --git a/packages/extension/src/runQueue.ts b/packages/extension/src/runQueue.ts index 89cb292..5f2caf6 100644 --- a/packages/extension/src/runQueue.ts +++ b/packages/extension/src/runQueue.ts @@ -3,6 +3,7 @@ import type { RunHandle } from './apiProcess' import type { ExtensionDiagnostic } from './diagnostic' import type { ImportsBreakdownProvider } from './importsBreakdownProvider' import type { TestTree } from './testTree' +import type { TraceReportManager } from './traceReport' import { VitestProcessAPI } from './apiProcess' import { log } from './log' import { ContinuousTestRunner, TestRunner } from './runner' @@ -33,6 +34,7 @@ export class RunQueue { private readonly api: VitestProcessAPI, private readonly diagnostic: ExtensionDiagnostic | undefined, private readonly importsBreakdown: ImportsBreakdownProvider, + private readonly traceReports: TraceReportManager, ) {} public isContinuousTestItem(testItem: vscode.TestItem): boolean { @@ -205,6 +207,7 @@ export class RunQueue { api || this.api, this.diagnostic, this.importsBreakdown, + this.traceReports, ) } @@ -216,6 +219,7 @@ export class RunQueue { this.api, this.diagnostic, this.importsBreakdown, + this.traceReports, this.testRunProfile, this.continuousRequests, ) diff --git a/packages/extension/src/runner.ts b/packages/extension/src/runner.ts index c38b076..1089d4a 100644 --- a/packages/extension/src/runner.ts +++ b/packages/extension/src/runner.ts @@ -4,6 +4,7 @@ import type { RunHandle, VitestProcessAPI } from './apiProcess' import type { ExtensionDiagnostic } from './diagnostic' import type { ImportsBreakdownProvider } from './importsBreakdownProvider' import type { TestTree } from './testTree' +import type { TraceReportManager } from './traceReport' import crypto from 'node:crypto' import path from 'node:path' import { stripVTControlCharacters } from 'node:util' @@ -40,6 +41,7 @@ export class TestRunner extends vscode.Disposable { protected readonly api: VitestProcessAPI, protected readonly diagnostic: ExtensionDiagnostic | undefined, protected readonly importsBreakdown: ImportsBreakdownProvider, + protected readonly traceReports?: TraceReportManager, ) { super(() => { log.verbose?.('Disposing test runner') @@ -110,6 +112,9 @@ export class TestRunner extends vscode.Disposable { }) handle.handlers.onTestRunEnd(async (files, unhandledError, collecting, coverage) => { + if (!collecting && this.api.htmlReportPath) { + this.traceReports?.update(this.api.id, this.api.htmlReportPath, files, this.tree) + } const testRun = this.testRun if (!testRun) { @@ -369,10 +374,11 @@ export class ContinuousTestRunner extends TestRunner { api: VitestProcessAPI, diagnostic: ExtensionDiagnostic | undefined, importsBreakdown: ImportsBreakdownProvider, + traceReports: TraceReportManager, private readonly testRunProfile: vscode.TestRunProfile, private readonly continuousRequests: Set, ) { - super(handle, controller, tree, api, diagnostic, importsBreakdown) + super(handle, controller, tree, api, diagnostic, importsBreakdown, traceReports) handle.handlers.onTestRunStart((files) => { this.startTestRun(files) log.verbose?.( diff --git a/packages/extension/src/spawn/terminal.ts b/packages/extension/src/spawn/terminal.ts index 793efc4..03aad80 100644 --- a/packages/extension/src/spawn/terminal.ts +++ b/packages/extension/src/spawn/terminal.ts @@ -96,6 +96,7 @@ export async function createVitestTerminalProcess( handlers: meta.handlers, pkg, workspaceSource: meta.workspaceSource, + htmlReportPath: meta.htmlReportPath, process: vitestProcess, projects: meta.projects, dispose: meta.dispose, diff --git a/packages/extension/src/spawn/ws.ts b/packages/extension/src/spawn/ws.ts index b25b20a..09db351 100644 --- a/packages/extension/src/spawn/ws.ts +++ b/packages/extension/src/spawn/ws.ts @@ -115,6 +115,7 @@ export function onWsConnection( onStart({ rpc: api, workspaceSource: message.workspaceSource, + htmlReportPath: message.htmlReportPath, handlers, projects: message.projects.map((p) => { if (p.dir) { diff --git a/packages/extension/src/traceReport.ts b/packages/extension/src/traceReport.ts new file mode 100644 index 0000000..b5429de --- /dev/null +++ b/packages/extension/src/traceReport.ts @@ -0,0 +1,92 @@ +import type { RunnerTask, RunnerTestFile } from 'vitest' +import type { TestTree } from './testTree' +import * as vscode from 'vscode' + +export interface TraceReportTarget { + apiId: string + reportPath: string + fileId: string + testId: string +} + +export function findTraceReportTargets( + apiId: string, + reportPath: string, + files: RunnerTestFile[], +): TraceReportTarget[] { + const targets: TraceReportTarget[] = [] + + function visit(task: RunnerTask, fileId: string) { + if (task.type === 'test') { + const artifacts = (task as any).artifacts as { type: string }[] | undefined + if (artifacts?.some((artifact) => artifact.type === 'internal:browserTrace')) { + targets.push({ apiId, reportPath, fileId, testId: task.id }) + } + return + } + task.tasks.forEach((child) => visit(child, fileId)) + } + + files.forEach((file) => visit(file, file.id)) + return targets +} + +export function createTraceReportUri(target: TraceReportTarget) { + const params = new URLSearchParams({ + file: target.fileId, + view: 'editor', + test: target.testId, + traceStep: '0', + }) + return vscode.Uri.file(target.reportPath).with({ fragment: `/?${params}` }) +} + +export class TraceReportManager { + private targets = new Map() + + clear() { + this.targets.clear() + } + + update(apiId: string, reportPath: string, files: RunnerTestFile[], tree: TestTree) { + for (const [item, target] of this.targets) { + if (target.apiId === apiId) { + this.targets.delete(item) + } + } + + for (const target of findTraceReportTargets(apiId, reportPath, files)) { + const item = tree.getTestItemByTaskId(target.testId) + if (item) { + this.targets.set(item, target) + } + } + } + + async open(testItem: vscode.TestItem | undefined) { + const target = testItem && this.targets.get(testItem) + if (!target) { + await vscode.window.showInformationMessage( + 'No trace report is available for this test. Run it with browser.traceView and the HTML reporter enabled.', + ) + return + } + + const uri = createTraceReportUri(target) + try { + await vscode.workspace.fs.stat(uri.with({ fragment: '' })) + } catch { + await vscode.window.showWarningMessage( + `The Vitest HTML report does not exist at ${target.reportPath}.`, + ) + return + } + + const commands = await vscode.commands.getCommands(true) + if (commands.includes('workbench.action.browser.openFile')) { + await vscode.commands.executeCommand('workbench.action.browser.openFile', uri) + } else { + await vscode.env.openExternal(uri) + } + } +} diff --git a/packages/extension/src/worker/index.ts b/packages/extension/src/worker/index.ts index 91ca4b2..d47d5da 100644 --- a/packages/extension/src/worker/index.ts +++ b/packages/extension/src/worker/index.ts @@ -39,11 +39,8 @@ emitter.on('message', async function onMessage(message: any) { const workerPath = pathToFileURL(join(__dirname, workerName)) const initModule = await import(workerPath.toString()) - const { createWorker, reporter, projects, workspaceSource } = await initModule.initVitest( - vitestModule, - data, - emitter, - ) + const { createWorker, reporter, projects, workspaceSource, htmlReportPath } = + await initModule.initVitest(vitestModule, data, emitter) const worker = createWorker() @@ -73,7 +70,7 @@ emitter.on('message', async function onMessage(message: any) { }) worker.initRpc(rpc) reporter.initRpc(rpc) - emitter.ready(projects, workspaceSource, isLegacy, vitestModule.version) + emitter.ready(projects, workspaceSource, htmlReportPath, isLegacy, vitestModule.version) await worker.vitest.report('onInit', worker.vitest) } catch (err: any) { diff --git a/packages/shared/src/emitter.ts b/packages/shared/src/emitter.ts index 14e3ea2..b1379f3 100644 --- a/packages/shared/src/emitter.ts +++ b/packages/shared/src/emitter.ts @@ -11,10 +11,18 @@ abstract class WorkerEventEmitter { ready( projects: SerializedProject[], workspaceSource: string | false, + htmlReportPath: string | undefined, legacy: boolean, version: string | undefined, ) { - this.sendWorkerEvent({ type: 'ready', projects, workspaceSource, legacy, version }) + this.sendWorkerEvent({ + type: 'ready', + projects, + workspaceSource, + htmlReportPath, + legacy, + version, + }) } error(err: any) { diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index cfd4da4..957cc9e 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -193,6 +193,7 @@ export interface EventReady { type: 'ready' projects: SerializedProject[] workspaceSource: string | false + htmlReportPath?: string legacy: boolean // the actual runtime version, unlike VitestPackage.version // this is also defined when vitest is resolved via yarn pnp diff --git a/packages/worker/src/index.ts b/packages/worker/src/index.ts index d5c5583..5a197ea 100644 --- a/packages/worker/src/index.ts +++ b/packages/worker/src/index.ts @@ -7,6 +7,7 @@ import type { Reporter, TestUserConfig } from 'vitest/node' import { Console } from 'node:console' import { Writable } from 'node:stream' import { toArray } from '@vitest/utils/helpers' +import { resolve } from 'pathe' import { VSCodeReporter } from './reporter' import { ExtensionWorker } from './worker' @@ -167,10 +168,19 @@ export async function initVitest( const workspaceSource: string | false = vitest.config.projects != null ? vitest.vite.config.configFile || false : false + let htmlReportPath: string | undefined + for (const reporter of vitest.config.reporters) { + if (Array.isArray(reporter) && reporter[0] === 'html') { + const options = reporter[1] as { outputDir?: string } + htmlReportPath = resolve(vitest.config.root, options.outputDir || '.vitest', 'index.html') + break + } + } return { vitest, reporter, workspaceSource, + htmlReportPath, projects, meta, createWorker() { diff --git a/test/unit/traceReport.test.ts b/test/unit/traceReport.test.ts new file mode 100644 index 0000000..367cd88 --- /dev/null +++ b/test/unit/traceReport.test.ts @@ -0,0 +1,60 @@ +import { expect } from 'chai' +import { + createTraceReportUri, + findTraceReportTargets, +} from '../../packages/extension/src/traceReport' + +describe('trace reports', () => { + it('finds tests with browser trace artifacts', () => { + const targets = findTraceReportTargets('api', '/root/.vitest/index.html', [ + { + id: 'file-id', + type: 'suite', + tasks: [ + { + id: 'suite-id', + type: 'suite', + tasks: [ + { + id: 'trace-test-id', + type: 'test', + artifacts: [{ type: 'internal:browserTrace' }], + }, + { + id: 'regular-test-id', + type: 'test', + artifacts: [], + }, + ], + }, + ], + } as any, + ]) + + expect(targets).to.deep.equal([ + { + apiId: 'api', + reportPath: '/root/.vitest/index.html', + fileId: 'file-id', + testId: 'trace-test-id', + }, + ]) + }) + + it('creates a URL that opens the first trace step', () => { + const uri = createTraceReportUri({ + apiId: 'api', + reportPath: '/root/.vitest/index.html', + fileId: 'file-id', + testId: 'test-id', + }) + + expect(uri.fsPath.replaceAll('\\', '/')).to.equal('/root/.vitest/index.html') + expect(Object.fromEntries(new URLSearchParams(uri.fragment.slice(2)))).to.deep.equal({ + file: 'file-id', + view: 'editor', + test: 'test-id', + traceStep: '0', + }) + }) +}) -- 2.51.2