Skip to content

Commit 2adc4a9

Browse files
authored
feat: imports duration breakdown (#695)
1 parent 5b2d66d commit 2adc4a9

File tree

17 files changed

+456
-167
lines changed

17 files changed

+456
-167
lines changed

README.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,7 @@ These options are resolved relative to the [workspace file](https://code.visuals
8383
- `vitest.debuggerPort`: Port that the debugger will be attached to. By default uses 9229 or tries to find a free port if it's not available.
8484
- `vitest.debuggerAddress`: TCP/IP address of process to be debugged. Default: localhost
8585
- `vitest.cliArguments`: Additional arguments to pass to the Vitest CLI. Note that some arguments will be ignored: `watch`, `reporter`, `api`, and `ui`. Example: `--mode=staging`
86+
- `vitest.showImportsDuration`: Show how long it took to import and transform the modules. When hovering, the extension provides more diagnostics.
8687

8788
> 💡 The `vitest.nodeExecutable` and `vitest.nodeExecArgs` settings are used as `execPath` and `execArgv` when spawning a new `child_process`, and as `runtimeExecutable` and `runtimeArgs` when [debugging a test](https://github.com/microsoft/vscode-js-debug/blob/main/OPTIONS.md).
8889
> The `vitest.terminalShellPath` and `vitest.terminalShellArgs` settings are used as `shellPath` and `shellArgs` when creating a new [terminal](https://code.visualstudio.com/api/references/vscode-api#Terminal)
@@ -111,6 +112,16 @@ You can also type the same command in the quick picker while the file is open.
111112

112113
![Reveal test in explorer](./img/reveal-in-picker.png "Reveal test in explorer")
113114

115+
### Import Breakdown
116+
117+
If you use Vitest 4.0.15 or higher, the extension will show how long it took to load the module on the same line where the import is defined. This number includes transform time and evaluation time, including static imports.
118+
119+
If you hover over it, you can get a more detailed diagnostic.
120+
121+
![Import breakdown example](./img/import-breakdown.png "Import breakdown example")
122+
123+
You can disable this feature by turning off `vitest.showImportsDuration`.
124+
114125
### Experimental
115126

116127
If the extension hangs, consider enabling `vitest.experimentalStaticAstCollect` option to use static analysis instead of actually running the test file every time you make a change which can cause visible hangs if it takes a long time to setup the test.

img/import-breakdown.png

363 KB
Loading

package.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -262,6 +262,11 @@
262262
"description": "Show a squiggly line where the error was thrown. This also enables the error count in the File Tab.",
263263
"type": "boolean",
264264
"default": true
265+
},
266+
"vitest.showImportsDuration": {
267+
"description": "Show how long it took to import the module during the last test run. If multiple isolated tests imported the module, the times will be aggregated.",
268+
"type": "boolean",
269+
"default": true
265270
}
266271
}
267272
}

packages/extension/src/api.ts

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { ExtensionTestSpecification } from 'vitest-vscode-shared'
1+
import type { ExtensionTestSpecification, ModuleDefinitionDurationsDiagnostic } from 'vitest-vscode-shared'
22
import type { VitestPackage } from './api/pkg'
33
import type { ExtensionWorkerEvents, VitestExtensionRPC } from './api/rpc'
44
import type { ExtensionWorkerProcess } from './api/types'
@@ -44,6 +44,45 @@ export class VitestAPI {
4444
return this.api.forEach(callback)
4545
}
4646

47+
async getSourceModuleDiagnostic(moduleId: string) {
48+
const allDiagnostic = await Promise.all(
49+
this.folderAPIs.map(api => api.getSourceModuleDiagnostic(moduleId)),
50+
)
51+
const modules = allDiagnostic[0]?.modules || []
52+
const untrackedModules = allDiagnostic[0]?.untrackedModules || []
53+
54+
type TimeDiagnostic = Pick<ModuleDefinitionDurationsDiagnostic, 'selfTime' | 'totalTime' | 'transformTime' | 'resolvedId'>
55+
const aggregateModules = (aggregatedModule: TimeDiagnostic, currentMod: TimeDiagnostic) => {
56+
if (aggregatedModule.resolvedId === currentMod.resolvedId) {
57+
aggregatedModule.selfTime += currentMod.selfTime
58+
aggregatedModule.totalTime += currentMod.totalTime
59+
if (aggregatedModule.transformTime != null && currentMod.transformTime != null) {
60+
aggregatedModule.transformTime += currentMod.transformTime
61+
}
62+
}
63+
}
64+
65+
// aggregate time from _other_ diagnostics that could've potentially imported this file
66+
for (let i = 1; i < allDiagnostic.length; i++) {
67+
const currentDiagnostic = allDiagnostic[i]
68+
currentDiagnostic.modules.forEach((mod, index) => {
69+
const aggregatedModule = modules[index]
70+
71+
aggregateModules(aggregatedModule, mod)
72+
})
73+
currentDiagnostic.untrackedModules.forEach((mod, index) => {
74+
const aggregatedModule = untrackedModules[index]
75+
76+
aggregateModules(aggregatedModule, mod)
77+
})
78+
}
79+
80+
return {
81+
modules,
82+
untrackedModules,
83+
}
84+
}
85+
4786
getModuleEnvironments(moduleId: string) {
4887
return Promise.all(
4988
this.api.map(async (api) => {
@@ -127,6 +166,10 @@ export class VitestFolderAPI {
127166
return this.meta.rpc.getTransformedModule(project, environment, moduleId)
128167
}
129168

169+
getSourceModuleDiagnostic(moduleId: string) {
170+
return this.meta.rpc.getSourceModuleDiagnostic(moduleId)
171+
}
172+
130173
async getModuleEnvironments(moduleId: string) {
131174
return this.meta.rpc.getModuleEnvironments(moduleId)
132175
}

packages/extension/src/config.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,7 @@ export function getConfig(workspaceFolder?: WorkspaceFolder) {
9797
debuggerPort: get<number>('debuggerPort') || undefined,
9898
debuggerAddress: get<string>('debuggerAddress', undefined) || undefined,
9999
logLevel,
100+
showImportsDuration: get<boolean>('showImportsDuration', true) ?? true,
100101
}
101102
}
102103

packages/extension/src/debug.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import type { VitestPackage } from './api/pkg'
22
import type { ExtensionWorkerProcess } from './api/types'
33
import type { WsConnectionMetadata } from './api/ws'
44
import type { ExtensionDiagnostic } from './diagnostic'
5+
import type { ImportsBreakdownProvider } from './importsBreakdownProvider'
56
import type { TestTree } from './testTree'
67
import crypto from 'node:crypto'
78
import { createServer } from 'node:http'
@@ -26,6 +27,7 @@ export async function debugTests(
2627
tree: TestTree,
2728
pkg: VitestPackage,
2829
diagnostic: ExtensionDiagnostic | undefined,
30+
importsBreakdown: ImportsBreakdownProvider,
2931

3032
request: vscode.TestRunRequest,
3133
token: vscode.CancellationToken,
@@ -152,6 +154,7 @@ export async function debugTests(
152154
tree,
153155
api,
154156
diagnostic,
157+
importsBreakdown,
155158
)
156159
disposables.push(api, runner)
157160

packages/extension/src/extension.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { configGlob, workspaceGlob } from './constants'
1010
import { coverageContext } from './coverage'
1111
import { DebugManager, debugTests } from './debug'
1212
import { ExtensionDiagnostic } from './diagnostic'
13+
import { ImportsBreakdownProvider } from './importsBreakdownProvider'
1314
import { log } from './log'
1415
import { TestRunner } from './runner'
1516
import { SchemaProvider } from './schemaProvider'
@@ -43,6 +44,7 @@ class VitestExtension {
4344
private diagnostic: ExtensionDiagnostic | undefined
4445
private debugManager: DebugManager
4546
private schemaProvider: SchemaProvider
47+
private importsBreakdownProvider: ImportsBreakdownProvider
4648

4749
/** @internal */
4850
_debugDisposable: vscode.Disposable | undefined
@@ -66,6 +68,12 @@ class VitestExtension {
6668
this.testTree = new TestTree(this.testController, this.loadingTestItem, this.schemaProvider)
6769
this.tagsManager = new TagsManager(this.testTree)
6870
this.debugManager = new DebugManager()
71+
this.importsBreakdownProvider = new ImportsBreakdownProvider(
72+
async (moduleId: string) => this.api?.getSourceModuleDiagnostic(moduleId) || {
73+
modules: [],
74+
untrackedModules: [],
75+
},
76+
)
6977
}
7078

7179
private _defineTestProfilePromise: Promise<void> | undefined
@@ -158,6 +166,7 @@ class VitestExtension {
158166
this.testTree,
159167
api,
160168
this.diagnostic,
169+
this.importsBreakdownProvider,
161170
)
162171
this.runners.push(runner)
163172

@@ -200,6 +209,7 @@ class VitestExtension {
200209
this.testTree,
201210
api.package,
202211
this.diagnostic,
212+
this.importsBreakdownProvider,
203213

204214
request,
205215
token,
@@ -483,6 +493,7 @@ class VitestExtension {
483493
this.tagsManager.dispose()
484494
this.testController.dispose()
485495
this.schemaProvider.dispose()
496+
this.importsBreakdownProvider.dispose()
486497
this.runProfiles.forEach(profile => profile.dispose())
487498
this.runProfiles.clear()
488499
this.disposables.forEach(d => d.dispose())
Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
import type { SourceModuleDiagnostic } from 'vitest-vscode-shared'
2+
import { relative } from 'node:path'
3+
import { pathToFileURL } from 'node:url'
4+
import * as vscode from 'vscode'
5+
import { getConfig } from './config'
6+
import { log } from './log'
7+
8+
export class ImportsBreakdownProvider {
9+
private disposables: vscode.Disposable[] = []
10+
private decorationType: vscode.TextEditorDecorationType
11+
12+
private _decorations = new Map<string, vscode.DecorationOptions[]>()
13+
14+
private showDecorations = getConfig().showImportsDuration
15+
16+
constructor(
17+
private getSourceModuleDiagnostic: (moduleId: string) => Promise<SourceModuleDiagnostic>,
18+
) {
19+
// Create a decoration type with gray color
20+
this.decorationType = vscode.window.createTextEditorDecorationType({
21+
after: {
22+
color: '#808080',
23+
margin: '0 0 0 0.5em',
24+
},
25+
})
26+
27+
// Update decorations when the active editor changes
28+
this.disposables.push(
29+
vscode.window.onDidChangeActiveTextEditor((editor) => {
30+
if (editor) {
31+
this.updateDecorations(editor)
32+
}
33+
}),
34+
)
35+
36+
// Update decorations when the document changes
37+
this.disposables.push(
38+
vscode.workspace.onDidChangeTextDocument((event) => {
39+
const editor = vscode.window.activeTextEditor
40+
if (editor && event.document === editor.document) {
41+
this.updateDecorations(editor)
42+
}
43+
}),
44+
)
45+
46+
this.disposables.push(
47+
vscode.workspace.onDidChangeConfiguration((event) => {
48+
if (event.affectsConfiguration('vitest.showImportsDuration')) {
49+
this.showDecorations = getConfig().showImportsDuration
50+
51+
this.refreshCurrentDecorations()
52+
}
53+
}),
54+
)
55+
56+
// Update decorations for the currently active editor
57+
if (vscode.window.activeTextEditor) {
58+
this.updateDecorations(vscode.window.activeTextEditor)
59+
}
60+
}
61+
62+
public refreshCurrentDecorations() {
63+
log.info('[DECOR] Reset all decorations.')
64+
this._decorations.clear()
65+
66+
// Update decorations for the currently active editor
67+
if (vscode.window.activeTextEditor) {
68+
this.updateDecorations(vscode.window.activeTextEditor)
69+
}
70+
}
71+
72+
private async updateDecorations(editor: vscode.TextEditor) {
73+
const document = editor.document
74+
if (!this.showDecorations || document.uri.scheme !== 'file' || !document.lineCount) {
75+
editor.setDecorations(this.decorationType, [])
76+
return
77+
}
78+
const fsPath = document.uri.fsPath
79+
if (this._decorations.has(fsPath)) {
80+
log.info('[DECOR] Decorations for', fsPath, 'are already cached. Displaying them.')
81+
editor.setDecorations(this.decorationType, this._decorations.get(fsPath)!)
82+
return
83+
}
84+
85+
const diagnostic = await this.getSourceModuleDiagnostic(fsPath).catch(() => null)
86+
if (!diagnostic || !diagnostic.modules) {
87+
editor.setDecorations(this.decorationType, [])
88+
return
89+
}
90+
91+
const decorations: vscode.DecorationOptions[] = []
92+
93+
// TODO: untracked modules somehow?
94+
diagnostic.modules.forEach((diagnostic) => {
95+
const range = new vscode.Range(
96+
diagnostic.start.line - 1,
97+
diagnostic.start.column,
98+
diagnostic.end.line - 1,
99+
diagnostic.end.column,
100+
)
101+
102+
const overallTime = diagnostic.totalTime + (diagnostic.transformTime || 0)
103+
let color: string | undefined
104+
if (overallTime >= 500) {
105+
color = 'rgb(248 113 113 / 0.8)'
106+
}
107+
else if (overallTime >= 100) {
108+
color = 'rgb(251 146 60 / 0.8)'
109+
}
110+
111+
let diagnosticMessage = `
112+
### VITEST DIAGNOSTIC
113+
- It took **${formatPreciseTime(diagnostic.totalTime)}** to import this module, including static imports.
114+
- It took **${formatPreciseTime(diagnostic.selfTime)}** to import this modules, excluding static imports.
115+
- It took **${formatPreciseTime(diagnostic.transformTime || 0)}** to transform this module.`
116+
117+
if (diagnostic.external) {
118+
diagnosticMessage += `\n- This module was **externalized** to [${diagnostic.resolvedUrl}](${pathToFileURL(diagnostic.resolvedId).toString()})`
119+
}
120+
if (diagnostic.importer && document.fileName !== diagnostic.importer) {
121+
diagnosticMessage += `\n- This module was originally imported by [${relative(document.fileName, diagnostic.importer)}](${pathToFileURL(diagnostic.importer)})`
122+
}
123+
124+
diagnosticMessage += `\n\nYou can disable diagnostic by setting [\`vitest.showImportsDuration\`](command:workbench.action.openSettings?%5B%22vitest.showImportsDuration%22%5D) option in your VSCode settings to \`false\`.`
125+
const ms = new vscode.MarkdownString(diagnosticMessage)
126+
ms.isTrusted = true
127+
128+
decorations.push({
129+
range,
130+
hoverMessage: ms,
131+
renderOptions: {
132+
after: {
133+
color,
134+
contentText: formatTime(overallTime),
135+
},
136+
},
137+
})
138+
})
139+
140+
this._decorations.set(fsPath, decorations)
141+
142+
editor.setDecorations(this.decorationType, decorations)
143+
}
144+
145+
dispose() {
146+
this.decorationType.dispose()
147+
this.disposables.forEach(d => d.dispose())
148+
}
149+
}
150+
151+
function formatTime(time: number): string {
152+
if (time > 1000) {
153+
return `${(time / 1000).toFixed(2)}s`
154+
}
155+
return `${Math.round(time)}ms`
156+
}
157+
158+
function formatPreciseTime(time: number): string {
159+
if (time > 1000) {
160+
return `${(time / 1000).toFixed(2)}s`
161+
}
162+
return `${time.toFixed(2)}ms`
163+
}

packages/extension/src/runner.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import type { ParsedStack, RunnerTaskResult, TestError } from 'vitest'
22
import type { ExtensionTestSpecification } from 'vitest-vscode-shared'
33
import type { VitestFolderAPI } from './api'
44
import type { ExtensionDiagnostic } from './diagnostic'
5+
import type { ImportsBreakdownProvider } from './importsBreakdownProvider'
56
import type { TestTree } from './testTree'
67
import { rm } from 'node:fs/promises'
78
import path from 'node:path'
@@ -34,6 +35,7 @@ export class TestRunner extends vscode.Disposable {
3435
private readonly tree: TestTree,
3536
private readonly api: VitestFolderAPI,
3637
private readonly diagnostic: ExtensionDiagnostic | undefined,
38+
private readonly importsBreakdown: ImportsBreakdownProvider,
3739
) {
3840
super(() => {
3941
log.verbose?.('Disposing test runner')
@@ -94,6 +96,8 @@ export class TestRunner extends vscode.Disposable {
9496
if (collecting)
9597
return
9698

99+
this.importsBreakdown.refreshCurrentDecorations()
100+
97101
getTasks(file).forEach((task) => {
98102
const test = this.tree.getTestItemByTask(task)
99103
if (!test) {

0 commit comments

Comments
 (0)