Skip to content

Commit 95961e4

Browse files
authored
feat: track module execution totalTime and selfTime (#8027)
1 parent 910892e commit 95961e4

File tree

19 files changed

+276
-12
lines changed

19 files changed

+276
-12
lines changed

docs/advanced/api/test-module.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,5 +95,18 @@ interface ModuleDiagnostic {
9595
* This value is only available if the test was executed with `logHeapUsage` flag.
9696
*/
9797
readonly heap: number | undefined
98+
/**
99+
* The time spent importing every non-externalized dependency that Vitest has processed.
100+
*/
101+
readonly importDurations: Record<string, ImportDuration>
102+
}
103+
104+
/** The time spent importing & executing a non-externalized file. */
105+
interface ImportDuration {
106+
/** The time spent importing & executing the file itself, not counting all non-externalized imports that the file does. */
107+
selfTime: number
108+
109+
/** The time spent importing & executing the file and all its imports. */
110+
totalTime: number
98111
}
99112
```

packages/runner/src/collect.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,11 @@ export async function collectTests(
5454

5555
await runner.importFile(filepath, 'collect')
5656

57+
const durations = runner.getImportDurations?.()
58+
if (durations) {
59+
file.importDurations = durations
60+
}
61+
5762
const defaultTasks = await getDefaultSuite().collect(file)
5863

5964
const fileHooks = createSuiteHooks()
@@ -85,6 +90,11 @@ export async function collectTests(
8590
state: 'fail',
8691
errors: [error],
8792
}
93+
94+
const durations = runner.getImportDurations?.()
95+
if (durations) {
96+
file.importDurations = durations
97+
}
8898
}
8999

90100
calculateSuiteHash(file)

packages/runner/src/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ export type {
2323
Fixtures,
2424
HookCleanupCallback,
2525
HookListener,
26+
ImportDuration,
2627
InferFixturesTypes,
2728
OnTestFailedHandler,
2829
OnTestFinishedHandler,

packages/runner/src/types/runner.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import type { DiffOptions } from '@vitest/utils/diff'
22
import type {
33
File,
4+
ImportDuration,
45
SequenceHooks,
56
SequenceSetupFiles,
67
Suite,
@@ -154,6 +155,10 @@ export interface VitestRunner {
154155
* Function that is called when the runner attempts to get the value when `test.extend` is used with `{ injected: true }`
155156
*/
156157
injectValue?: (key: string) => unknown
158+
/**
159+
* Gets the time spent importing each individual non-externalized file that Vitest collected.
160+
*/
161+
getImportDurations?: () => Record<string, ImportDuration>
157162
/**
158163
* Publicly available configuration.
159164
*/

packages/runner/src/types/tasks.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,15 @@ export interface TaskResult {
155155
pending?: boolean
156156
}
157157

158+
/** The time spent importing & executing a non-externalized file. */
159+
export interface ImportDuration {
160+
/** The time spent importing & executing the file itself, not counting all non-externalized imports that the file does. */
161+
selfTime: number
162+
163+
/** The time spent importing & executing the file and all its imports. */
164+
totalTime: number
165+
}
166+
158167
/**
159168
* The tuple representing a single task update.
160169
* Usually reported after the task finishes.
@@ -239,6 +248,9 @@ export interface File extends Suite {
239248
* @internal
240249
*/
241250
local?: boolean
251+
252+
/** The time spent importing every non-externalized dependency that Vitest has processed. */
253+
importDurations?: Record<string, ImportDuration>
242254
}
243255

244256
export interface Test<ExtraContext = object> extends TaskPopulated {

packages/vite-node/src/client.ts

Lines changed: 88 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -176,7 +176,29 @@ export class ModuleCacheMap extends Map<string, ModuleCache> {
176176
}
177177
}
178178

179-
export type ModuleExecutionInfo = Map<string, { startOffset: number }>
179+
export type ModuleExecutionInfo = Map<string, ModuleExecutionInfoEntry>
180+
181+
export interface ModuleExecutionInfoEntry {
182+
startOffset: number
183+
184+
/** The duration that was spent executing the module. */
185+
duration: number
186+
187+
/** The time that was spent executing the module itself and externalized imports. */
188+
selfTime: number
189+
}
190+
191+
/** Stack to track nested module execution for self-time calculation. */
192+
type ExecutionStack = Array<{
193+
/** The file that is being executed. */
194+
filename: string
195+
196+
/** The start time of this module's execution. */
197+
startTime: number
198+
199+
/** Accumulated time spent importing all sub-imports. */
200+
subImportTime: number
201+
}>
180202

181203
export class ViteNodeRunner {
182204
root: string
@@ -189,6 +211,27 @@ export class ViteNodeRunner {
189211
*/
190212
moduleCache: ModuleCacheMap
191213

214+
/**
215+
* Tracks the stack of modules being executed for the purpose of calculating import self-time.
216+
*
217+
* Note that while in most cases, imports are a linear stack of modules,
218+
* this is occasionally not the case, for example when you have parallel top-level dynamic imports like so:
219+
*
220+
* ```ts
221+
* await Promise.all([
222+
* import('./module1'),
223+
* import('./module2'),
224+
* ]);
225+
* ```
226+
*
227+
* In this case, the self time will be reported incorrectly for one of the modules (could go negative).
228+
* As top-level awaits with dynamic imports like this are uncommon, we don't handle this case specifically.
229+
*/
230+
private executionStack: ExecutionStack = []
231+
232+
// `performance` can be mocked, so make sure we're using the original function
233+
private performanceNow = performance.now.bind(performance)
234+
192235
constructor(public options: ViteNodeRunnerOptions) {
193236
this.root = options.root ?? process.cwd()
194237
this.moduleCache = options.moduleCache ?? new ModuleCacheMap()
@@ -542,10 +585,51 @@ export class ViteNodeRunner {
542585
columnOffset: -codeDefinition.length,
543586
}
544587

545-
this.options.moduleExecutionInfo?.set(options.filename, { startOffset: codeDefinition.length })
588+
const finishModuleExecutionInfo = this.startCalculateModuleExecutionInfo(options.filename, codeDefinition.length)
546589

547-
const fn = vm.runInThisContext(code, options)
548-
await fn(...Object.values(context))
590+
try {
591+
const fn = vm.runInThisContext(code, options)
592+
await fn(...Object.values(context))
593+
}
594+
finally {
595+
this.options.moduleExecutionInfo?.set(options.filename, finishModuleExecutionInfo())
596+
}
597+
}
598+
599+
/**
600+
* Starts calculating the module execution info such as the total duration and self time spent on executing the module.
601+
* Returns a function to call once the module has finished executing.
602+
*/
603+
protected startCalculateModuleExecutionInfo(filename: string, startOffset: number): () => ModuleExecutionInfoEntry {
604+
const startTime = this.performanceNow()
605+
606+
this.executionStack.push({
607+
filename,
608+
startTime,
609+
subImportTime: 0,
610+
})
611+
612+
return () => {
613+
const duration = this.performanceNow() - startTime
614+
615+
const currentExecution = this.executionStack.pop()
616+
617+
if (currentExecution == null) {
618+
throw new Error('Execution stack is empty, this should never happen')
619+
}
620+
621+
const selfTime = duration - currentExecution.subImportTime
622+
623+
if (this.executionStack.length > 0) {
624+
this.executionStack.at(-1)!.subImportTime += duration
625+
}
626+
627+
return {
628+
startOffset,
629+
duration,
630+
selfTime,
631+
}
632+
}
549633
}
550634

551635
prepareContext(context: Record<string, any>): Record<string, any> {

packages/vitest/src/node/reporters/reported-tasks.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import type {
2+
ImportDuration,
23
Task as RunnerTask,
34
Test as RunnerTestCase,
45
File as RunnerTestFile,
@@ -467,13 +468,15 @@ export class TestModule extends SuiteImplementation {
467468
const environmentSetupDuration = this.task.environmentLoad || 0
468469
const duration = this.task.result?.duration || 0
469470
const heap = this.task.result?.heap
471+
const importDurations = this.task.importDurations ?? {}
470472
return {
471473
environmentSetupDuration,
472474
prepareDuration,
473475
collectDuration,
474476
setupDuration,
475477
duration,
476478
heap,
479+
importDurations,
477480
}
478481
}
479482
}
@@ -626,6 +629,10 @@ export interface ModuleDiagnostic {
626629
* This value is only available if the test was executed with `logHeapUsage` flag.
627630
*/
628631
readonly heap: number | undefined
632+
/**
633+
* The time spent importing every non-externalized dependency that Vitest has processed.
634+
*/
635+
readonly importDurations: Record<string, ImportDuration>
629636
}
630637

631638
function storeTask(

packages/vitest/src/public/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -270,6 +270,7 @@ export type {
270270
ExtendedContext,
271271
HookCleanupCallback,
272272
HookListener,
273+
ImportDuration,
273274
OnTestFailedHandler,
274275
OnTestFinishedHandler,
275276
RunMode,

packages/vitest/src/runtime/execute.ts

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -343,15 +343,20 @@ export class VitestExecutor extends ViteNodeRunner {
343343
columnOffset: -codeDefinition.length,
344344
}
345345

346-
this.options.moduleExecutionInfo?.set(options.filename, { startOffset: codeDefinition.length })
346+
const finishModuleExecutionInfo = this.startCalculateModuleExecutionInfo(options.filename, codeDefinition.length)
347347

348-
const fn = vm.runInContext(code, vmContext, {
349-
...options,
350-
// if we encountered an import, it's not inlined
351-
importModuleDynamically: this.externalModules
352-
.importModuleDynamically as any,
353-
} as any)
354-
await fn(...Object.values(context))
348+
try {
349+
const fn = vm.runInContext(code, vmContext, {
350+
...options,
351+
// if we encountered an import, it's not inlined
352+
importModuleDynamically: this.externalModules
353+
.importModuleDynamically as any,
354+
} as any)
355+
await fn(...Object.values(context))
356+
}
357+
finally {
358+
this.options.moduleExecutionInfo?.set(options.filename, finishModuleExecutionInfo())
359+
}
355360
}
356361

357362
public async importExternalModule(path: string): Promise<any> {

packages/vitest/src/runtime/runners/test.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import type { ExpectStatic } from '@vitest/expect'
22
import type {
33
CancelReason,
44
File,
5+
ImportDuration,
56
Suite,
67
Task,
78
TestContext,
@@ -12,6 +13,7 @@ import type { SerializedConfig } from '../config'
1213
import type { VitestExecutor } from '../execute'
1314
import { getState, GLOBAL_EXPECT, setState } from '@vitest/expect'
1415
import { getNames, getTestName, getTests } from '@vitest/runner/utils'
16+
import { normalize } from 'pathe'
1517
import { createExpect } from '../../integrations/chai/index'
1618
import { inject } from '../../integrations/inject'
1719
import { getSnapshotClient } from '../../integrations/snapshot/chai'
@@ -181,6 +183,17 @@ export class VitestTestRunner implements VitestRunner {
181183
})
182184
return context
183185
}
186+
187+
getImportDurations(): Record<string, ImportDuration> {
188+
const entries = [...(this.workerState.moduleExecutionInfo?.entries() ?? [])]
189+
return Object.fromEntries(entries.map(([filepath, { duration, selfTime }]) => [
190+
normalize(filepath),
191+
{
192+
selfTime,
193+
totalTime: duration,
194+
},
195+
]))
196+
}
184197
}
185198

186199
function clearModuleMocks(config: SerializedConfig) {

test/cli/test/reported-tasks.test.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -276,6 +276,15 @@ it('correctly builds the full name', () => {
276276
expect(suiteSecondLevel.fullName).toBe('a group > a nested group')
277277
})
278278

279+
it('correctly reports import durations', () => {
280+
const diagnostic = testModule.diagnostic()
281+
282+
const filePath = resolve(root, './1_first.test.ts')
283+
const importDuration = diagnostic.importDurations[filePath]
284+
expect(importDuration.selfTime).toBeGreaterThan(0)
285+
expect(importDuration.totalTime).toBeGreaterThan(0)
286+
})
287+
279288
function date(time: Date) {
280289
return `${time.getDate()}/${time.getMonth() + 1}/${time.getFullYear()}`
281290
}
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
await new Promise(resolve => setTimeout(resolve, 25))
2+
3+
throw new Error('test')
4+
5+
export {}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
await new Promise(resolve => setTimeout(resolve, 25))
2+
3+
export {}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
import './import-durations-25ms'
2+
3+
await new Promise(resolve => setTimeout(resolve, 50))
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
import { expect, test } from 'vitest'
2+
import './import-durations-25ms-throws'
3+
4+
test('works', async () => {
5+
expect(true).toBe(true)
6+
})
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
import { expect, test } from 'vitest'
2+
import './import-durations-50ms'
3+
4+
test('works', async () => {
5+
expect(true).toBe(true)
6+
})

test/reporters/tests/__snapshots__/html.test.ts.snap

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ exports[`html reporter > resolves to "failing" status for test file "json-fail"
1010
"file": [Circular],
1111
"filepath": "<rootDir>/test/reporters/fixtures/json-fail.test.ts",
1212
"id": 0,
13+
"importDurations": {},
1314
"meta": {},
1415
"mode": "run",
1516
"name": "json-fail.test.ts",
@@ -121,6 +122,7 @@ exports[`html reporter > resolves to "passing" status for test file "all-passing
121122
"file": [Circular],
122123
"filepath": "<rootDir>/test/reporters/fixtures/all-passing-or-skipped.test.ts",
123124
"id": 0,
125+
"importDurations": {},
124126
"meta": {},
125127
"mode": "run",
126128
"name": "all-passing-or-skipped.test.ts",

test/reporters/tests/html.test.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ describe('html reporter', async () => {
2727
file.environmentLoad = 0
2828
file.prepareDuration = 0
2929
file.setupDuration = 0
30+
file.importDurations = {}
3031
file.result.duration = 0
3132
file.result.startTime = 0
3233
const task = file.tasks[0]
@@ -55,6 +56,7 @@ describe('html reporter', async () => {
5556
file.environmentLoad = 0
5657
file.prepareDuration = 0
5758
file.setupDuration = 0
59+
file.importDurations = {}
5860
file.result.duration = 0
5961
file.result.startTime = 0
6062
const task = file.tasks[0]

0 commit comments

Comments
 (0)