feat(reporters): print import duration breakdown (#9105)

This commit is contained in:
Vladimir 2025-12-02 16:48:29 +01:00 committed by GitHub
parent aca647113a
commit 122ff321cc
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
64 changed files with 1973 additions and 242 deletions

View File

@ -37,31 +37,37 @@ button:focus:not(:focus-visible) {
html:not(.dark) .custom-block.tip code {
color: var(--vitest-custom-block-tip-code-text) !important;
}
html:not(.dark) .custom-block.info code {
color: var(--vitest-custom-block-info-code-text) !important;
}
.custom-block.tip a:hover,
.vp-doc .custom-block.tip a:hover > code {
.vp-doc .custom-block.tip a:hover>code {
color: var(--vp-c-brand-1) !important;
opacity: 1;
}
.custom-block.info a:hover,
.vp-doc .custom-block.info a:hover > code {
.vp-doc .custom-block.info a:hover>code {
color: var(--vp-c-brand-1) !important;
opacity: 1;
}
html:not(.dark) .custom-block.info a:hover,
html:not(.dark) .vp-doc .custom-block.info a:hover > code {
html:not(.dark) .vp-doc .custom-block.info a:hover>code {
color: var(--vitest-custom-block-info-code-text) !important;
opacity: 1;
}
.custom-block.warning a:hover,
.vp-doc .custom-block.warning a:hover > code {
.vp-doc .custom-block.warning a:hover>code {
color: var(--vp-c-warning-1) !important;
opacity: 1;
}
.custom-block.danger a:hover,
.vp-doc .custom-block.danger a:hover > code {
.vp-doc .custom-block.danger a:hover>code {
color: var(--vp-c-danger-1) !important;
opacity: 1;
}
@ -70,6 +76,7 @@ html:not(.dark) .vp-doc .custom-block.info a:hover > code {
:not(.dark) .title-icon {
opacity: 1 !important;
}
.dark .title-icon {
opacity: 0.67 !important;
}
@ -81,6 +88,7 @@ html:not(.dark) .vp-doc .custom-block.info a:hover > code {
.vp-doc a {
text-decoration-style: dotted;
}
.custom-block a:focus,
.custom-block a:active,
.custom-block a:hover,
@ -92,7 +100,8 @@ html:not(.dark) .vp-doc .custom-block.info a:hover > code {
text-decoration: underline;
}
.vp-doc th, .vp-doc td {
.vp-doc th,
.vp-doc td {
padding: 6px 10px;
border: 1px solid #8882;
}
@ -113,14 +122,17 @@ img.resizable-img {
.VPTeamMembersItem.medium .profile .data .affiliation {
min-height: unset;
}
.VPTeamMembersItem.medium .profile .data .desc {
min-height: unset;
}
/* fix height ~ 2 lines of text: 3 cards per row */
@media (min-width: 648px) {
.VPTeamMembersItem.medium .profile .data .affiliation {
min-height: 4rem;
}
.VPTeamMembersItem.medium .profile .data .desc {
min-height: 4rem;
}
@ -130,6 +142,7 @@ img.resizable-img {
.VPTeamMembersItem.small .profile .data .affiliation {
min-height: 3rem;
}
.VPTeamMembersItem.small .profile .data .desc {
min-height: 3rem;
}
@ -139,33 +152,40 @@ img.resizable-img {
.VPTeamMembersItem.small .profile .data .affiliation {
min-height: 4rem;
}
.VPTeamMembersItem.small .profile .data .desc {
min-height: 4rem;
}
}
/* fix height ~ 3 lines of text: 3 cards per row */
@media (min-width: 815px) and (max-width: 875px) {
.VPTeamMembersItem.small .profile .data .affiliation {
min-height: 4rem;
}
.VPTeamMembersItem.small .profile .data .desc {
min-height: 4rem;
}
}
/* fix height ~ 3 lines of text: 2 cards per row */
@media (max-width: 612px) {
.VPTeamMembersItem.small .profile .data .affiliation {
min-height: 4rem;
}
.VPTeamMembersItem.small .profile .data .desc {
min-height: 4rem;
}
}
/* fix height: one card per row */
@media (max-width: 568px) {
.VPTeamMembersItem.small .profile .data .affiliation {
min-height: unset;
}
.VPTeamMembersItem.small .profile .data .desc {
min-height: unset;
}
@ -176,3 +196,30 @@ img.resizable-img {
transition: background-color 0.5s;
display: inline-block;
}
/* credit goes to https://dylanatsmith.com/wrote/styling-the-kbd-element */
html:not(.dark) kbd {
--kbd-color-background: #f7f7f7;
--kbd-color-border: #cbcccd;
--kbd-color-text: #222325;
}
kbd {
--kbd-color-background: #898b90;
--kbd-color-border: #3d3e42;
--kbd-color-text: #222325;
background-color: var(--kbd-color-background);
color: var(--kbd-color-text);
border-radius: 0.25rem;
border: 1px solid var(--kbd-color-border);
box-shadow: 0 2px 0 1px var(--kbd-color-border);
font-family: var(--font-family-sans-serif);
font-size: 0.75em;
line-height: 1;
min-width: 0.75rem;
text-align: center;
padding: 2px 5px;
position: relative;
top: -1px;
}

View File

@ -142,7 +142,7 @@ Define a generator that will be applied before hashing the cache key.
Use this to make sure Vitest generates correct hash. It is a good idea to define this function if your plugin can be registered with different options.
This is called only if [`experimental.fsModuleCache`](/config/experimental#fsmodulecache) is defined.
This is called only if [`experimental.fsModuleCache`](/config/experimental#experimental-fsmodulecache) is defined.
```ts
interface PluginOptions {

View File

@ -120,3 +120,7 @@ interface ImportDuration {
totalTime: number
}
```
## viteEnvironment <Version type="experimental">4.0.15</Version> <Experimental /> {#viteenvironment}
This is a Vite's [`DevEnvironment`](https://vite.dev/guide/api-environment) that transforms all files inside of the test module.

View File

@ -564,7 +564,7 @@ function getSeed(): number | null
Returns the seed, if tests are running in a random order.
## experimental_parseSpecification <Version>4.0.0</Version> <Badge type="warning">experimental</Badge> {#parsespecification}
## experimental_parseSpecification <Version type="experimental">4.0.0</Version> <Experimental /> {#parsespecification}
```ts
function experimental_parseSpecification(
@ -595,7 +595,7 @@ Vitest will only collect tests defined in the file. It will never follow imports
Vitest collects all `it`, `test`, `suite` and `describe` definitions even if they were not imported from the `vitest` entry point.
:::
## experimental_parseSpecifications <Version>4.0.0</Version> <Badge type="warning">experimental</Badge> {#parsespecifications}
## experimental_parseSpecifications <Version type="experimental">4.0.0</Version> <Experimental /> {#parsespecifications}
```ts
function experimental_parseSpecifications(
@ -608,10 +608,67 @@ function experimental_parseSpecifications(
This method will [collect tests](#parsespecification) from an array of specifications. By default, Vitest will run only `os.availableParallelism()` number of specifications at a time to reduce the potential performance degradation. You can specify a different number in a second argument.
## experimental_clearCache <Version type="experimental">4.0.11</Version> <Badge type="warning">experimental</Badge> {#clearcache}
## experimental_clearCache <Version type="experimental">4.0.11</Version> <Experimental /> {#clearcache}
```ts
function experimental_clearCache(): Promise<void>
```
Deletes all Vitest caches, including [`experimental.fsModuleCache`](/config/experimental#experimental-fsmodulecache).
## experimental_getSourceModuleDiagnostic <Version type="experimental">4.0.15</Version> <Experimental /> {#getsourcemodulediagnostic}
```ts
export function experimental_getSourceModuleDiagnostic(
moduleId: string,
testModule?: TestModule,
): Promise<SourceModuleDiagnostic>
```
::: details Types
```ts
export interface ModuleDefinitionLocation {
line: number
column: number
}
export interface SourceModuleLocations {
modules: ModuleDefinitionDiagnostic[]
untracked: ModuleDefinitionDiagnostic[]
}
export interface ModuleDefinitionDiagnostic {
start: ModuleDefinitionLocation
end: ModuleDefinitionLocation
startIndex: number
endIndex: number
url: string
resolvedId: string
}
export interface ModuleDefinitionDurationsDiagnostic extends ModuleDefinitionDiagnostic {
selfTime: number
totalTime: number
external?: boolean
}
export interface UntrackedModuleDefinitionDiagnostic {
url: string
resolvedId: string
selfTime: number
totalTime: number
external?: boolean
}
export interface SourceModuleDiagnostic {
modules: ModuleDefinitionDurationsDiagnostic[]
untrackedModules: UntrackedModuleDefinitionDiagnostic[]
}
```
:::
Returns module's diagnostic. If [`testModule`](/api/advanced/test-module) is not provided, `selfTime` and `totalTime` will be aggregated across all tests that were running the last time. If the module was not transformed or executed, the diagnostic will be empty.
::: warning
At the moment, the [browser](/guide/browser/) modules are not supported.
:::

View File

@ -137,3 +137,21 @@ export default defineConfig({
::: warning
It's important that Node can process `sdkPath` content because it is not transformed by Vitest. See [the guide](/guide/open-telemetry) on how to work with OpenTelemetry inside of Vitest.
:::
## experimental.printImportBreakdown <Version type="experimental">4.0.15</Version> {#experimental-printimportbreakdown}
- **Type:** `boolean`
- **Default:** `false`
Show import duration breakdown after tests have finished running. This option only works with [`default`](/guide/reporters#default), [`verbose`](/guide/reporters#verbose), or [`tree`](/guide/reporters#tree) reporters.
- Self: the time it took to import the module, excluding static imports;
- Total: the time it took to import the module, including static imports. Note that this does not include `transform` time of the current module.
<img alt="An example of import breakdown in the terminal" src="/reporter-import-breakdown.png" />
Note that if the file path is too long, Vitest will truncate it at the start until it fits 45 character limit.
::: info
[Vitest UI](/guide/ui#import-breakdown) shows a breakdown of imports automatically if at least one file took longer than 500 milliseconds to load. You can manually set this option to `false` to disable this.
:::

View File

@ -8,7 +8,7 @@ If enabled, Vitest integration generates spans that are scoped to your test's wo
OpenTelemetry initialization increases the startup time of every test unless Vitest runs without [isolation](/config/isolate). You can see it as the `vitest.runtime.traces` span inside `vitest.worker.start`.
:::
To start using OpenTelemetry in Vitest, specify an SDK module path via [`experimental.openTelemetry.sdkPath`](/config/experimental#opentelemetry) and set `experimental.openTelemetry.enabled` to `true`. Vitest will automatically instrument the whole process and each individual test worker.
To start using OpenTelemetry in Vitest, specify an SDK module path via [`experimental.openTelemetry.sdkPath`](/config/experimental#experimental-opentelemetry) and set `experimental.openTelemetry.enabled` to `true`. Vitest will automatically instrument the whole process and each individual test worker.
Make sure to export the SDK as a default export, so that Vitest can flush the network requests before the process is closed. Note that Vitest doesn't automatically call `start`.

View File

@ -52,3 +52,92 @@ npx vite preview --outDir ./html
You can configure output with [`outputFile`](/config/#outputfile) config option. You need to specify `.html` path there. For example, `./html/index.html` is the default value.
:::
## Module Graph
Module Graph's tab displays the module graph of the selected test file.
::: info
All of the provided images use [Zammad](https://github.com/zammad/zammad) repository as an example.
:::
<img alt="The module graph view" img-light src="/ui/light-module-graph.png">
<img alt="The module graph view" img-dark src="/ui/dark-module-graph.png">
If there are more than 50 modules, the module graph displays only the first two levels of the graph to reduce the visual clutter. You can always click on "Show Full Graph" icon to preview the full graph.
<center>
<img alt="The 'Show Full Graph' button located close to the legend" img-light src="/ui/light-ui-show-graph.png">
<img alt="The 'Show Full Graph' button located close to the legend" img-dark src="/ui/dark-ui-show-graph.png">
</center>
::: warning
Note that if your graph is too big, it may take some time before the node positions are stabilized.
:::
You can always restore the entry module graph by clicking on "Reset". To expand the module graph, right-click or hold <kbd>Shift</kbd> while clicking the node that interests you. It will display all nodes related to the selected one.
By default, Vitest doesn't show the modules from `node_modules`. Usually, these modules are externalized. You can enable them by deselecting "Hide node_modules".
### Module Info
By left-clicking on the module node, you open the Module Info view.
<img alt="The module info view for an inlined module" img-light src="/ui/light-module-info.png">
<img alt="The module info view for an inlined module" img-dark src="/ui/dark-module-info.png">
This view is separated into two parts. The top part shows the full module ID and some diagnostics about the module. If [`experimental.fsModuleCache`](/config/experimental#experimental-fsmodulecache) is enabled, there will be a "cached" or "not cached" badge. On the right you can see time diagnostics:
- Self Time: the time it took to import the module, excluding static imports.
- Total Time: the time it took to import the module, including static imports. Note that this does not include `transform` time of the current module.
- Transform: the time it took to transform the module.
If you opened this view by clicking on an import, you will also see a "Back" button at the start that will take you to the previous module.
The bottom part depends on the module type. If the module is external, you will only see the source code of that file. You will not be able to traverse the module graph any further, and you won't see how long it took to import static imports.
<img alt="The module info view for an external module" img-light src="/ui/light-module-info-external.png">
<img alt="The module info view for an external module" img-dark src="/ui/dark-module-info-external.png">
If the module was inlined, you will see three more windows:
- Source: unchanged source code of the module
- Transformed: the transformed code that Vitest executes using Vite's [module runner](https://vite.dev/guide/api-environment-runtimes#modulerunner)
- Source Map (v3): source map mappings
All static imports in the "Source" window show a total time it took to evaluate them by the current module. If the import was already evaluated in the module graph, it will show `0ms` because it is cached by that point.
If the module took longer than 500 milliseconds to load, the time will be displayed in red. If the module took longer than 100 milliseconds, the time will be displayed in orange.
You can click on an import source to jump into that module and traverse the graph further (note `./support/assertions/index.ts` below).
<img alt="The module info view for an internal module" img-light src="/ui/light-module-info-traverse.png">
<img alt="The module info view for an internal module" img-dark src="/ui/dark-module-info-traverse.png">
::: warning
Note that type-only imports are not executed at runtime and do not display a total duration. They also cannot be opened.
:::
If another plugin injects a module import during transformation, those imports will be displayed at the start of the module in gray colour (for example, modules injected by `import.meta.glob`). They also show the total time and can be traversed further.
<img alt="The module info view for an internal module" img-light src="/ui/light-module-info-shadow.png">
<img alt="The module info view for an internal module" img-dark src="/ui/dark-module-info-shadow.png">
::: tip
If you are developing a custom integration on top of Vitest, you can use [`vitest.experimental_getSourceModuleDiagnostic`](/api/advanced/vitest#getsourcemodulediagnostic) to retrieve this information.
:::
### Import Breakdown
The Module Graph tab also provides an Import Breakdown with a list of modules that take the longest time to load (top 10 by default, but you can press "Show more" to load 10 more), sorted by Total Time.
<img alt="Import breakdown with a list of top 10 modules that take the longest time to load" img-light src="/ui/light-import-breakdown.png">
<img alt="Import breakdown with a list of top 10 modules that take the longest time to load" img-dark src="/ui/dark-import-breakdown.png">
You can click on the module to see the Module Info. If the module is external, it will have the yellow color (the same color in the module graph).
The breakdown shows a list of modules with self time, total time, and a percentage relative to the time it took to load the whole test file.
The "Show Import Breakdown" icon will have a red color if there is at least one file that took longer than 500 milliseconds to load, and it will be orange if there is at least one file that took longer than 100 milliseconds.
By default, Vitest shows the breakdown automatically if there is at least one module that took longer than 500 milliseconds to load. You can control the behaviour by setting the [`experimental.printImportBreakdown`](/config/experimental#experimental-printimportbreakdown) option.

Binary file not shown.

After

Width:  |  Height:  |  Size: 186 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 323 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 282 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 296 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 439 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 426 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 426 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 326 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 286 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 299 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 480 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 422 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 422 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

View File

@ -62,6 +62,7 @@ export function createBrowserRunner(
super(options.config)
this.config = options.config
this.commands = getBrowserState().commands
this.viteEnvironment = '__browser__'
}
setMethod(method: TestExecutionMethod) {

View File

@ -36,7 +36,7 @@ export async function collectTests(
async () => {
const testLocations = typeof spec === 'string' ? undefined : spec.testLocations
const file = createFileTask(filepath, config.root, config.name, runner.pool)
const file = createFileTask(filepath, config.root, config.name, runner.pool, runner.viteEnvironment)
setFileContext(file, Object.create(null))
file.shuffle = config.sequence.shuffle

View File

@ -188,6 +188,10 @@ export interface VitestRunner {
* The name of the current pool. Can affect how stack trace is inferred on the server side.
*/
pool?: string
/**
* The current Vite environment that processes the files on the server.
*/
viteEnvironment?: string
/**
* Return the worker context for fixtures specified with `scope: 'worker'`

View File

@ -203,6 +203,12 @@ export interface ImportDuration {
/** The time spent importing & executing the file and all its imports. */
totalTime: number
/** Will be set to `true`, if the module was externalized. In this case totalTime and selfTime are identical. */
external?: boolean
/** Which module imported this module first. All subsequent imports are cached. */
importer?: string
}
/**
@ -277,6 +283,10 @@ export interface File extends Suite {
* @default 'forks'
*/
pool?: string
/**
* The environment that processes the file on the server.
*/
viteEnvironment?: string
/**
* The path to the file in UNIX format.
*/

View File

@ -182,6 +182,7 @@ export function createFileTask(
root: string,
projectName: string | undefined,
pool?: string,
viteEnvironment?: string,
): File {
const path = relative(root, filepath)
const file: File = {
@ -196,6 +197,7 @@ export function createFileTask(
projectName,
file: undefined!,
pool,
viteEnvironment,
}
file.file = file
return file

View File

@ -0,0 +1,19 @@
<script setup lang="ts">
const { type = 'info' } = defineProps<{
type?: 'danger' | 'warning' | 'info' | 'tip' | 'custom'
}>()
</script>
<template>
<span
class="rounded-full py-0.5 px-2 text-xs text-white select-none"
:class="{
'bg-red': type === 'danger',
'bg-orange': type === 'warning',
'bg-gray': type === 'info',
'bg-indigo/60': type === 'tip',
}"
>
<slot />
</span>
</template>

View File

@ -1,4 +1,5 @@
<script setup lang="ts">
import type { EditorFromTextArea } from 'codemirror'
import type { Ref } from 'vue'
import { onMounted, ref, useAttrs } from 'vue'
import { codemirrorRef, useCodeMirror } from '~/composables/codemirror'
@ -11,6 +12,7 @@ const { mode, readOnly } = defineProps<{
const emit = defineEmits<{
(event: 'save', content: string): void
(event: 'codemirror', codemirror: EditorFromTextArea): void
}>()
const modelValue = defineModel<string>()
@ -18,9 +20,9 @@ const modelValue = defineModel<string>()
const attrs = useAttrs()
const modeMap: Record<string, any> = {
// html: 'htmlmixed',
// vue: 'htmlmixed',
// svelte: 'htmlmixed',
html: 'htmlmixed',
vue: 'htmlmixed',
svelte: 'htmlmixed',
js: 'javascript',
mjs: 'javascript',
cjs: 'javascript',
@ -54,10 +56,17 @@ onMounted(async () => {
},
},
})
codemirror.on('refresh', () => {
emit('codemirror', codemirror)
})
codemirror.on('change', () => {
emit('codemirror', codemirror)
})
codemirror.setSize('100%', '100%')
codemirror.clearHistory()
codemirrorRef.value = codemirror
setTimeout(() => codemirrorRef.value!.refresh(), 100)
setTimeout(() => codemirrorRef.value?.refresh(), 100)
})
</script>

View File

@ -0,0 +1,118 @@
<script setup lang="ts">
import type { ModuleType } from '~/composables/module-graph'
import { relative } from 'pathe'
import { computed, ref } from 'vue'
import { config } from '~/composables/client'
import { currentModule } from '~/composables/navigation'
import { formatTime, getDurationClass, getExternalModuleName } from '~/utils/task'
interface ImportEntry {
importedFile: string
relativeFile: string
selfTime: number
totalTime: number
formattedSelfTime: string
formattedTotalTime: string
selfTimeClass: string | undefined
totalTimeClass: string | undefined
external?: boolean
}
const emit = defineEmits<{
select: [moduleId: string, type: ModuleType]
}>()
const maxAmount = ref(10)
const sortedImports = computed(() => {
const file = currentModule.value
const importDurations = file?.importDurations
if (!importDurations) {
return []
}
const root = config.value.root
const allImports: ImportEntry[] = []
for (const filePath in importDurations) {
const duration = importDurations[filePath]
const raltiveModule = duration.external
? getExternalModuleName(filePath)
: relative(root, filePath)
allImports.push({
importedFile: filePath,
relativeFile: ellipsisFile(raltiveModule),
selfTime: duration.selfTime,
totalTime: duration.totalTime,
formattedSelfTime: formatTime(duration.selfTime),
formattedTotalTime: formatTime(duration.totalTime),
selfTimeClass: getDurationClass(duration.selfTime),
totalTimeClass: getDurationClass(duration.totalTime),
external: duration.external,
})
}
const sortedImports = allImports.sort((a, b) => b.totalTime - a.totalTime)
return sortedImports
})
const imports = computed(() => sortedImports.value.slice(0, maxAmount.value))
function ellipsisFile(moduleId: string) {
if (moduleId.length <= 45) {
return moduleId
}
return `...${moduleId.slice(-45)}`
}
</script>
<template>
<div class="overflow-auto max-h-120">
<h1 my-2 mx-4>
Import Duration Breakdown <span op-70>(ordered by Total Time) (Top {{ Math.min(maxAmount, imports.length) }})</span>
</h1>
<table my-2 mx-4 text-sm font-light op-90>
<thead>
<tr>
<th>
Module
</th>
<th>
Self
</th>
<th>
Total
</th>
<th>
%
</th>
</tr>
</thead>
<tbody>
<tr v-for="row of imports" :key="row.importedFile">
<td
class="cursor-pointer pr-2"
:style="{ color: row.external ? 'var(--color-node-external)' : undefined }"
@click="emit('select', row.importedFile, row.external ? 'external' : 'inline')"
>
{{ row.relativeFile }}
</td>
<td pr-2 :class="row.selfTimeClass">
{{ row.formattedSelfTime }}
</td>
<td pr-2 :class="row.totalTimeClass">
{{ row.formattedTotalTime }}
</td>
<td pr-2 :class="row.totalTimeClass">
{{ Math.round((row.totalTime / sortedImports[0].totalTime) * 100) }}%
</td>
</tr>
</tbody>
</table>
<button
v-if="maxAmount < sortedImports.length"
class="flex w-full justify-center h-8 text-sm z-10 relative font-light"
@click="maxAmount += 5"
>
Show more
</button>
</div>
</template>

View File

@ -1,27 +1,198 @@
<script setup lang="ts">
import type { Editor, EditorFromTextArea, LineWidget, TextMarker } from 'codemirror'
import type { Experimental, ExternalResult, TransformResultWithSource } from 'vitest'
import type { ModuleType } from '~/composables/module-graph'
import { asyncComputed, onKeyStroke } from '@vueuse/core'
import { Tooltip as VueTooltip } from 'floating-vue'
import { join, relative } from 'pathe'
import { computed } from 'vue'
import { browserState, client } from '~/composables/client'
import { browserState, client, config } from '~/composables/client'
import { currentModule } from '~/composables/navigation'
import { formatPreciseTime, formatTime, getDurationClass, getImportDurationType } from '~/utils/task'
import Badge from './Badge.vue'
import CodeMirrorContainer from './CodeMirrorContainer.vue'
import IconButton from './IconButton.vue'
const props = defineProps<{ id: string; projectName: string }>()
const emit = defineEmits<{ (e: 'close'): void }>()
const props = defineProps<{
id: string
projectName: string
type: ModuleType
canUndo: boolean
}>()
const result = asyncComputed(() =>
client.rpc.getTransformResult(props.projectName, props.id, !!browserState),
)
const emit = defineEmits<{
(e: 'close'): void
(e: 'select', id: string, type: ModuleType): void
(e: 'back'): void
}>()
const result = asyncComputed<TransformResultWithSource | ExternalResult | undefined>(() => {
if (!currentModule.value?.id) {
return undefined
}
if (props.type === 'inline') {
return client.rpc.getTransformResult(props.projectName, props.id, currentModule.value.id, !!browserState)
}
if (props.type === 'external') {
return client.rpc.getExternalResult(props.id, currentModule.value.id)
}
})
const durations = computed(() => {
const importDurations = currentModule.value?.importDurations || {}
return importDurations[props.id] || importDurations[join('/@fs/', props.id)] || {}
})
const ext = computed(() => props.id?.split(/\./g).pop() || 'js')
const source = computed(() => result.value?.source?.trim() || '')
const isCached = computed(() => {
if (!result.value || !('code' in result.value) || !config.value.experimental?.fsModuleCache) {
return undefined
}
const index = result.value.code.lastIndexOf('vitestCache=')
return index !== -1
})
const code = computed(
() =>
result.value?.code?.replace(/\/\/# sourceMappingURL=.*\n/, '').trim() || '',
() => {
if (!result.value || !('code' in result.value)) {
return null
}
return result.value.code
.replace(/\/\/# sourceMappingURL=.*\n/, '')
.replace(/\/\/# sourceMappingSource=.*\n/, '')
.replace(/\/\/# vitestCache=.*\n?/, '')
.trim() || ''
},
)
const sourceMap = computed(() => ({
mappings: result.value?.map?.mappings ?? '',
version: (result.value?.map as any)?.version,
}))
const sourceMap = computed(() => {
if (!result.value || !('map' in result.value)) {
return {
mappings: '',
}
}
return {
mappings: result.value?.map?.mappings ?? '',
version: (result.value?.map as any)?.version,
}
})
const widgetElements: HTMLDivElement[] = []
const markers: TextMarker[] = []
const lineWidgets: LineWidget[] = []
function onMousedown(editor: Editor, e: MouseEvent) {
const lineCh = editor.coordsChar({ left: e.clientX, top: e.clientY })
const markers = editor.findMarksAt(lineCh)
if (markers.length !== 1) {
return
}
const resolvedUrl = markers[0].title
if (resolvedUrl) {
const type = markers[0].attributes?.['data-external'] === 'true' ? 'external' : 'inline'
emit('select', resolvedUrl, type)
}
}
function buildShadowImportsHtml(imports: Experimental.UntrackedModuleDefinitionDiagnostic[]) {
const shadowImportsDiv = document.createElement('div')
shadowImportsDiv.classList.add('mb-5')
imports.forEach(({ resolvedId, totalTime, external }) => {
const importDiv = document.createElement('div')
importDiv.append(document.createTextNode('import '))
const sourceDiv = document.createElement('span')
const url = relative(config.value.root, resolvedId)
sourceDiv.textContent = `"/${url}"`
sourceDiv.className = 'hover:underline decoration-gray cursor-pointer select-none'
importDiv.append(sourceDiv)
sourceDiv.addEventListener('click', () => {
emit('select', resolvedId, external ? 'external' : 'inline')
})
const timeElement = document.createElement('span')
timeElement.textContent = ` ${formatTime(totalTime)}`
const durationClass = getDurationClass(totalTime)
if (durationClass) {
timeElement.classList.add(durationClass)
}
importDiv.append(timeElement)
shadowImportsDiv.append(importDiv)
})
return shadowImportsDiv
}
function createDurationDiv(duration: number) {
const timeElement = document.createElement('div')
timeElement.className = 'flex ml-2'
timeElement.textContent = formatTime(duration)
const durationClass = getDurationClass(duration)
if (durationClass) {
timeElement.classList.add(durationClass)
}
return timeElement
}
function markImportDurations(codemirror: EditorFromTextArea) {
lineWidgets.forEach(lw => lw.clear())
lineWidgets.length = 0
widgetElements.forEach(el => el.remove())
widgetElements.length = 0
markers.forEach(m => m.clear())
markers.length = 0
if (result.value && 'modules' in result.value) {
codemirror.off('mousedown', onMousedown)
codemirror.on('mousedown', onMousedown)
const untrackedModules = result.value.untrackedModules
if (untrackedModules?.length) {
const importDiv = buildShadowImportsHtml(untrackedModules)
widgetElements.push(importDiv)
lineWidgets.push(codemirror.addLineWidget(0, importDiv, { above: true }))
}
result.value.modules?.forEach((diagnostic) => {
const start = {
line: diagnostic.start.line - 1,
ch: diagnostic.start.column,
}
const end = {
line: diagnostic.end.line - 1,
ch: diagnostic.end.column,
}
const marker = codemirror.markText(start, end, {
title: diagnostic.resolvedId,
attributes: {
'data-external': String(diagnostic.external === true),
},
className: 'hover:underline decoration-red cursor-pointer select-none',
})
markers.push(marker)
const timeElement = createDurationDiv(diagnostic.totalTime + (diagnostic.transformTime || 0))
if (!untrackedModules?.length) {
timeElement.classList.add('-mt-5')
}
widgetElements.push(timeElement)
codemirror.addWidget(
{
line: diagnostic.end.line - 1,
ch: diagnostic.end.column + 1,
},
timeElement,
false,
)
})
}
}
function goBack() {
emit('back')
}
onKeyStroke('Escape', () => {
emit('close')
@ -32,7 +203,75 @@ onKeyStroke('Escape', () => {
<template>
<div w-350 max-w-screen h-full flex flex-col>
<div p-4 relative>
<p>Module Info</p>
<div flex justify-between>
<p>
<IconButton
v-if="canUndo"
v-tooltip.bottom="'Go Back'"
icon="i-carbon-arrow-left"
class="flex-inline"
@click="goBack()"
/>
Module Info
<VueTooltip class="inline" cursor-help>
<Badge type="custom" ml-1 :style="{ backgroundColor: `var(--color-node-${type})` }">
{{ type }}
</Badge>
<template #popper>
This is module is {{ type === 'external' ? 'externalized' : 'inlined' }}.
<template v-if="type === 'external'">
It means that the module was not processed by Vite plugins, but instead was directly imported by the environment.
</template>
<template v-else>
It means that the module was processed by Vite plugins.
</template>
</template>
</VueTooltip>
<VueTooltip v-if="isCached === true" class="inline" cursor-help>
<Badge type="tip" ml-2>
cached
</Badge>
<template #popper>
This module is cached on the file system under `experimental.fsModuleCachePath` ("node_modules/.exprtimental-vitest-cache" by default).
</template>
</VueTooltip>
<VueTooltip v-if="isCached === false" class="inline" cursor-help>
<Badge type="warning" ml-2>
not cached
</Badge>
<template #popper>
<p>This module is not cached on the file system. It might be the first test run after cache invalidation or</p>
<p>it was excluded manually via `experimental_defineCacheKeyGenerator`, or it cannot be cached (modules with `import.meta.glob`, for example).</p>
</template>
</VueTooltip>
</p>
<div mr-8 flex gap-2 items-center>
<VueTooltip v-if="durations.selfTime != null && durations.external !== true" class="inline" cursor-help>
<Badge :type="getImportDurationType(durations.selfTime)">
self: {{ formatTime(durations.selfTime) }}
</Badge>
<template #popper>
It took {{ formatPreciseTime(durations.selfTime) }} to import this module, excluding static imports.
</template>
</VueTooltip>
<VueTooltip v-if="durations.totalTime != null" class="inline" cursor-help>
<Badge :type="getImportDurationType(durations.totalTime)">
total: {{ formatTime(durations.totalTime) }}
</Badge>
<template #popper>
It took {{ formatPreciseTime(durations.totalTime) }} to import the whole module, including static imports.
</template>
</VueTooltip>
<VueTooltip v-if="result && 'transformTime' in result && result.transformTime" class="inline" cursor-help>
<Badge :type="getImportDurationType(result.transformTime)">
transform: {{ formatTime(result.transformTime) }}
</Badge>
<template #popper>
It took {{ formatPreciseTime(result.transformTime) }} to transform this module by Vite plugins.
</template>
</VueTooltip>
</div>
</div>
<p op50 font-mono text-sm>
{{ id }}
</p>
@ -49,26 +288,29 @@ onKeyStroke('Escape', () => {
No transform result found for this module.
</div>
<template v-else>
<div grid="~ cols-2 rows-[min-content_auto]" overflow-hidden flex-auto>
<div grid="~ rows-[min-content_auto]" overflow-hidden flex-auto :class="{ 'cols-2': code != null }">
<div p="x3 y-1" bg-overlay border="base b t r">
Source
</div>
<div p="x3 y-1" bg-overlay border="base b t">
<div v-if="code != null" p="x3 y-1" bg-overlay border="base b t">
Transformed
</div>
<CodeMirrorContainer
:key="id"
h-full
:model-value="source"
read-only
v-bind="{ lineNumbers: true }"
:mode="ext"
@codemirror="markImportDurations($event)"
/>
<CodeMirrorContainer
v-if="code != null"
h-full
:model-value="code"
read-only
v-bind="{ lineNumbers: true }"
:mode="ext"
mode="js"
/>
</div>
<div v-if="sourceMap.mappings !== ''">
@ -79,7 +321,6 @@ onKeyStroke('Escape', () => {
:model-value="sourceMap.mappings"
read-only
v-bind="{ lineNumbers: true }"
:mode="ext"
/>
</div>
</template>

View File

@ -8,16 +8,20 @@ import type {
ModuleNode,
ModuleType,
} from '~/composables/module-graph'
import { useRefHistory } from '@vueuse/core'
import {
defineGraphConfig,
defineNode,
GraphController,
Markers,
PositionInitializers,
} from 'd3-graph-controller'
import { onMounted, onUnmounted, ref, toRefs, watch, watchEffect } from 'vue'
import { isReport } from '~/composables/client'
import { computed, onMounted, onUnmounted, ref, shallowRef, toRefs, watch } from 'vue'
import { config, isReport } from '~/composables/client'
import { currentModule } from '~/composables/navigation'
import IconButton from '../IconButton.vue'
import Modal from '../Modal.vue'
import ViewModuleGraphImportBreakdown from '../ModuleGraphImportBreakdown.vue'
import ModuleTransformResultView from '../ModuleTransformResultView.vue'
const props = defineProps<{
@ -32,19 +36,30 @@ const { graph } = toRefs(props)
const el = ref<HTMLDivElement>()
const modalShow = ref(false)
const selectedModule = ref<string | null>()
const selectedModule = ref<{ id: string; type: ModuleType } | null>()
const selectedModuleHistory = useRefHistory(selectedModule)
const controller = ref<ModuleGraphController | undefined>()
watchEffect(
() => {
if (modalShow.value === false) {
setTimeout(() => (selectedModule.value = undefined), 300)
const focusedNode = ref<string | null>(null)
const filteredGraph = shallowRef<ModuleGraph>(graph.value)
const breakdownIconClass = computed(() => {
let textClass = ''
const importDurations = currentModule.value?.importDurations || {}
for (const moduleId in importDurations) {
const { totalTime } = importDurations[moduleId]
if (totalTime >= 500) {
textClass = 'text-red'
break
}
},
{ flush: 'post' },
)
else if (totalTime >= 100) {
textClass = 'text-orange'
}
}
return textClass
})
const breakdownShow = ref(config.value?.experimental?.printImportBreakdown ?? breakdownIconClass.value === 'text-red')
onMounted(() => {
filteredGraph.value = filterGraphByLevels(graph.value, null, 2)
resetGraphController()
})
@ -52,17 +67,198 @@ onUnmounted(() => {
controller.value?.shutdown()
})
watch(graph, () => resetGraphController())
watch(graph, () => {
filteredGraph.value = filterGraphByLevels(graph.value, focusedNode.value, 2)
resetGraphController()
})
function showFullGraph() {
filteredGraph.value = graph.value
resetGraphController()
}
function toggleImportBreakdown() {
breakdownShow.value = !breakdownShow.value
}
function filterGraphByLevels(
sourceGraph: ModuleGraph,
startNodeId: string | null,
levels: number = 2,
): ModuleGraph {
if (!sourceGraph.nodes.length || sourceGraph.nodes.length <= 50) {
return sourceGraph
}
// Build adjacency list for efficient traversal
const adjacencyList = new Map<string, Set<string>>()
sourceGraph.nodes.forEach(node => adjacencyList.set(node.id, new Set()))
sourceGraph.links.forEach((link) => {
const sourceId = typeof link.source === 'object' ? link.source.id : String(link.source)
const targetId = typeof link.target === 'object' ? link.target.id : String(link.target)
adjacencyList.get(sourceId)?.add(targetId)
adjacencyList.get(targetId)?.add(sourceId)
})
let startNodes: string[]
if (startNodeId) {
startNodes = [startNodeId]
}
else {
// Find root node (node with type 'inline' that appears as source but not target, or first inline node)
const targetIds = new Set(sourceGraph.links.map(link =>
typeof link.target === 'object' ? link.target.id : String(link.target),
))
const rootCandidates = sourceGraph.nodes.filter(
node => node.type === 'inline' && !targetIds.has(node.id),
)
startNodes = rootCandidates.length > 0
? [rootCandidates[0].id]
: [sourceGraph.nodes[0].id]
}
// BFS to find all nodes within N levels
const visitedNodes = new Set<string>()
const queue: Array<{ id: string; level: number }> = startNodes.map(id => ({ id, level: 0 }))
while (queue.length > 0) {
const { id, level } = queue.shift()!
if (visitedNodes.has(id) || level > levels) {
continue
}
visitedNodes.add(id)
if (level < levels) {
const neighbors = adjacencyList.get(id) || new Set()
neighbors.forEach((neighborId) => {
if (!visitedNodes.has(neighborId)) {
queue.push({ id: neighborId, level: level + 1 })
}
})
}
}
const nodeMap = new Map(sourceGraph.nodes.map(node => [node.id, node]))
const filteredNodes = Array.from(visitedNodes)
.map(id => nodeMap.get(id))
.filter(node => node !== undefined) as ModuleNode[]
const filteredNodeMap = new Map(filteredNodes.map(node => [node.id, node]))
const filteredLinks = sourceGraph.links
.map((link) => {
const sourceId = typeof link.source === 'object' ? link.source.id : String(link.source)
const targetId = typeof link.target === 'object' ? link.target.id : String(link.target)
// Only include links where both nodes are in the filtered set
if (visitedNodes.has(sourceId) && visitedNodes.has(targetId)) {
const sourceNode = filteredNodeMap.get(sourceId)
const targetNode = filteredNodeMap.get(targetId)
if (sourceNode && targetNode) {
return {
...link,
source: sourceNode,
target: targetNode,
}
}
}
return null
})
.filter(link => link !== null) as ModuleLink[]
return {
nodes: filteredNodes,
links: filteredLinks,
}
}
function setFilter(name: ModuleType, value: boolean) {
controller.value?.filterNodesByType(value, name)
}
function setSelectedModule(id: string) {
selectedModule.value = id
function setSelectedModule(id: string, type: ModuleType) {
selectedModule.value = { id, type }
modalShow.value = true
}
function selectPreviousModule() {
selectedModuleHistory.undo()
}
function closeResultView() {
modalShow.value = false
selectedModuleHistory.clear()
}
function focusOnNode(nodeId: string) {
focusedNode.value = nodeId
filteredGraph.value = filterGraphByLevels(graph.value, nodeId, 2)
updateNodeColors()
resetGraphController()
}
function resetToRoot() {
focusedNode.value = null
filteredGraph.value = filterGraphByLevels(graph.value, null, 2)
updateNodeColors()
resetGraphController()
}
function updateNodeColors() {
const updatedNodes = filteredGraph.value.nodes.map((node) => {
let color: string
let labelColor: string
if (node.id === focusedNode.value) {
color = 'var(--color-node-focused)'
labelColor = 'var(--color-node-focused)'
}
else if (node.type === 'inline') {
const originalColor = node.color
const isRoot = originalColor === 'var(--color-node-root)'
color = isRoot ? 'var(--color-node-root)' : 'var(--color-node-inline)'
labelColor = color
}
else {
color = 'var(--color-node-external)'
labelColor = 'var(--color-node-external)'
}
return defineNode<ModuleType, ModuleNode>({
...node,
color,
label: node.label
? {
...node.label,
color: labelColor,
}
: node.label,
})
})
const nodeMap = new Map(updatedNodes.map(node => [node.id, node]))
const updatedLinks = filteredGraph.value.links.map((link) => {
const sourceId = typeof link.source === 'object' ? link.source.id : String(link.source)
const targetId = typeof link.target === 'object' ? link.target.id : String(link.target)
return {
...link,
source: nodeMap.get(sourceId)!,
target: nodeMap.get(targetId)!,
}
})
filteredGraph.value = {
nodes: updatedNodes,
links: updatedLinks,
}
}
function resetGraphController(reset = false) {
controller.value?.shutdown()
@ -73,13 +269,33 @@ function resetGraphController(reset = false) {
return
}
if (!graph.value || !el.value) {
if (!filteredGraph.value || !el.value) {
return
}
const nodesLength = filteredGraph.value.nodes.length
let zoom = 1
let min = 0.5
if (nodesLength > 300) {
zoom = 0.3
min = 0.2
}
else if (nodesLength > 200) {
zoom = 0.4
min = 0.3
}
else if (nodesLength > 100) {
zoom = 0.5
min = 0.3
}
else if (nodesLength > 50) {
zoom = 0.7
zoom = 0.4
}
controller.value = new GraphController(
el.value!,
graph.value,
filteredGraph.value,
// See https://graph-controller.yeger.eu/config/ for more options
defineGraphConfig<ModuleType, ModuleNode, ModuleLink>({
nodeRadius: 10,
@ -92,7 +308,7 @@ function resetGraphController(reset = false) {
if (willBeHidden) {
return 0
}
return 0.25
return 0.05
},
},
forces: {
@ -100,7 +316,7 @@ function resetGraphController(reset = false) {
radiusMultiplier: 10,
},
link: {
length: 240,
length: 140,
},
},
},
@ -108,19 +324,20 @@ function resetGraphController(reset = false) {
modifiers: {
node: bindOnClick,
},
positionInitializer:
graph.value.nodes.length > 1
? PositionInitializers.Randomized
: PositionInitializers.Centered,
positionInitializer: graph.value.nodes.length === 1
? PositionInitializers.Centered
: PositionInitializers.Randomized,
zoom: {
min: 0.5,
max: 2,
initial: zoom,
min,
max: 1.5,
},
}),
)
}
const isValidClick = (event: PointerEvent) => event.button === 0
const isRightClick = (event: PointerEvent) => event.button === 2
function bindOnClick(
selection: Selection<SVGCircleElement, ModuleNode, SVGGElement, undefined>,
@ -128,51 +345,78 @@ function bindOnClick(
if (isReport) {
return
}
// Only trigger on left-click and primary touch
// Handle both left-click (focus) and right-click (open modal)
let px = 0
let py = 0
let pt = 0
let isRightClickDown = false
selection
.on('pointerdown', (event: PointerEvent, node) => {
if (node.type === 'external') {
if (!node.x || !node.y) {
return
}
if (!node.x || !node.y || !isValidClick(event)) {
isRightClickDown = isRightClick(event)
if (!isValidClick(event) && !isRightClickDown) {
return
}
px = node.x
py = node.y
pt = Date.now()
})
.on('pointerup', (event: PointerEvent, node: ModuleNode) => {
if (node.type === 'external') {
if (!node.x || !node.y) {
return
}
if (!node.x || !node.y || !isValidClick(event)) {
const wasRightClick = isRightClick(event)
if (!isValidClick(event) && !wasRightClick) {
return
}
if (Date.now() - pt > 500) {
return
}
const dx = node.x - px
const dy = node.y - py
if (dx ** 2 + dy ** 2 < 100) {
setSelectedModule(node.id)
// Left-click: show details (open modal)
if (!wasRightClick && !event.shiftKey) {
setSelectedModule(node.id, node.type)
}
// Right-click or Shift+Click: expand graph (focus on node)
else if (wasRightClick || event.shiftKey) {
event.preventDefault()
if (node.type === 'inline') {
focusOnNode(node.id)
}
}
}
})
.on('contextmenu', (event: PointerEvent) => {
// Prevent default context menu
event.preventDefault()
})
}
</script>
<template>
<div h-full min-h-75 flex-1 overflow="hidden">
<div>
<div flex items-center gap-4 px-3 py-2>
<div flex items-center gap-2 px-3 py-2>
<div
flex="~ gap-1"
items-center
select-none
>
<div class="pr-2">
{{ filteredGraph.nodes.length }}/{{ graph.nodes.length }} {{ filteredGraph.nodes.length === 1 ? 'module' : 'modules' }}
</div>
<input
id="hide-node-modules"
v-model="hideNodeModules"
@ -217,23 +461,53 @@ function bindOnClick(
>{{ node }} Modules</label>
</div>
<div flex-auto />
<div
flex="~ gap-2"
items-center
text-xs
opacity-60
>
<span>Click on node: details Right-click/Shift: expand graph</span>
</div>
<div>
<IconButton
v-tooltip.bottom="`${breakdownShow ? 'Hide' : 'Show'} Import Breakdown`"
icon="i-carbon-notebook"
:class="breakdownIconClass"
@click="toggleImportBreakdown()"
/>
</div>
<div>
<IconButton
v-tooltip.bottom="'Show Full Graph'"
icon="i-carbon-ibm-cloud-direct-link-2-connect"
@click="showFullGraph()"
/>
</div>
<div>
<IconButton
v-tooltip.bottom="'Reset'"
icon="i-carbon-reset"
@click="resetGraphController(true)"
@click="resetToRoot()"
/>
</div>
</div>
</div>
<div v-if="breakdownShow" class="absolute bg-[#eee] dark:bg-[#222] border-base right-0 mr-2 rounded-xl mt-2">
<ViewModuleGraphImportBreakdown @select="(id, type) => setSelectedModule(id, type)" />
</div>
<div ref="el" />
<Modal v-model="modalShow" direction="right">
<template v-if="selectedModule">
<Suspense>
<ModuleTransformResultView
:id="selectedModule"
:id="selectedModule.id"
:project-name="projectName"
@close="modalShow = false"
:type="selectedModule.type"
:can-undo="selectedModuleHistory.undoStack.value.length > 1"
@close="closeResultView()"
@select="(id, type) => setSelectedModule(id, type)"
@back="selectPreviousModule()"
/>
</Suspense>
</template>
@ -245,9 +519,10 @@ function bindOnClick(
:root {
--color-link-label: var(--color-text);
--color-link: #ddd;
--color-node-external: #c0ad79;
--color-node-external: #6C5C33;
--color-node-inline: #8bc4a0;
--color-node-root: #6e9aa5;
--color-node-focused: #e67e22;
--color-node-label: var(--color-text);
--color-node-stroke: var(--color-text);
}
@ -255,9 +530,10 @@ function bindOnClick(
html.dark {
--color-text: #fff;
--color-link: #333;
--color-node-external: #857a40;
--color-node-external: #c0ad79;
--color-node-inline: #468b60;
--color-node-root: #467d8b;
--color-node-focused: #f39c12;
}
.graph {

View File

@ -61,6 +61,7 @@ export function createStaticClient(): VitestClient {
getUnhandledErrors: () => {
return metadata.unhandledErrors
},
getExternalResult: asyncNoop,
getTransformResult: asyncNoop,
onDone: noop,
onTaskUpdate: noop,

View File

@ -11,7 +11,7 @@ import { selectedTest } from './params'
import 'codemirror/mode/javascript/javascript'
// import 'codemirror/mode/css/css'
import 'codemirror/mode/xml/xml'
// import 'codemirror/mode/htmlmixed/htmlmixed'
import 'codemirror/mode/htmlmixed/htmlmixed'
import 'codemirror/mode/jsx/jsx'
import 'codemirror/addon/display/placeholder'
import 'codemirror/addon/scroll/simplescrollbars'
@ -67,7 +67,7 @@ export function useCodeMirror(
export async function showTaskSource(task: Task) {
navigateTo({
file: task.file.id,
line: task.location?.line ?? 0,
line: task.location?.line ?? 1,
view: 'editor',
test: task.id,
column: null,

View File

@ -7,6 +7,7 @@ import type {
} from 'd3-graph-controller'
import type { ModuleGraphData } from 'vitest'
import { defineGraph, defineLink, defineNode } from 'd3-graph-controller'
import { calcExternalLabels, createModuleLabelItem } from '~/utils/task'
export type ModuleType = 'external' | 'inline'
export type ModuleNode = GraphNode<ModuleType>
@ -19,95 +20,20 @@ export type ModuleGraphController = GraphController<
>
export type ModuleGraphConfig = GraphConfig<ModuleType, ModuleNode, ModuleLink>
export interface ModuleLabelItem {
id: string
raw: string
splits: string[]
candidate: string
finished: boolean
}
export function calcExternalLabels(
labels: ModuleLabelItem[],
): Map<string, string> {
const result: Map<string, string> = new Map()
const splitMap: Map<string, number[]> = new Map()
const firsts: number[] = []
while (true) {
let finishedCount = 0
labels.forEach((label, i) => {
const { splits, finished } = label
// record the candidate as final label text when label is marked finished
if (finished) {
finishedCount++
const { raw, candidate } = label
result.set(raw, candidate)
return
}
if (splits.length === 0) {
label.finished = true
return
}
const head = splits[0]
if (splitMap.has(head)) {
label.candidate += label.candidate === '' ? head : `/${head}`
splitMap.get(head)?.push(i)
splits.shift()
}
else {
splitMap.set(head, [i])
// record the index of the label where the head first appears
firsts.push(i)
}
})
// update candidate of label which index appears in first array
firsts.forEach((i) => {
const label = labels[i]
const head = label.splits.shift()
label.candidate += label.candidate === '' ? head : `/${head}`
})
splitMap.forEach((value) => {
if (value.length === 1) {
const index = value[0]
labels[index].finished = true
}
})
splitMap.clear()
firsts.length = 0
if (finishedCount === labels.length) {
break
}
}
return result
}
export function createModuleLabelItem(module: string): ModuleLabelItem {
let raw = module
if (raw.includes('/node_modules/')) {
raw = module.split(/\/node_modules\//g).pop()!
}
const splits = raw.split(/\//g)
return {
raw,
splits,
candidate: '',
finished: false,
id: module,
}
}
function defineExternalModuleNodes(modules: string[]): ModuleNode[] {
const labels: ModuleLabelItem[] = modules.map(module =>
const labels = modules.map(module =>
createModuleLabelItem(module),
)
const map = calcExternalLabels(labels)
return labels.map(({ raw, id }) => {
return labels.map(({ raw, id, splits }) => {
return defineNode<ModuleType, ModuleNode>({
color: 'var(--color-node-external)',
label: {
color: 'var(--color-node-external)',
fontSize: '0.875rem',
text: map.get(raw) ?? '',
text: id.includes('node_modules')
? (map.get(raw) ?? raw)
: splits.pop()!,
},
isFocused: false,
id,

View File

@ -18,6 +18,121 @@ export function caseInsensitiveMatch(target: string, str2: string) {
return target.toLowerCase().includes(str2.toLowerCase())
}
export function formatTime(time: number): string {
if (time > 1000) {
return `${(time / 1000).toFixed(2)}s`
}
return `${Math.round(time)}ms`
}
export function formatPreciseTime(time: number): string {
if (time > 1000) {
return `${(time / 1000).toFixed(2)}s`
}
return `${time.toFixed(2)}ms`
}
export interface ModuleLabelItem {
id: string
raw: string
splits: string[]
candidate: string
finished: boolean
}
export function calcExternalLabels(
labels: ModuleLabelItem[],
): Map<string, string> {
const result: Map<string, string> = new Map()
const splitMap: Map<string, number[]> = new Map()
const firsts: number[] = []
while (true) {
let finishedCount = 0
labels.forEach((label, i) => {
const { splits, finished } = label
// record the candidate as final label text when label is marked finished
if (finished) {
finishedCount++
const { raw, candidate } = label
result.set(raw, candidate)
return
}
if (splits.length === 0) {
label.finished = true
return
}
const head = splits[0]
if (splitMap.has(head)) {
label.candidate += label.candidate === '' ? head : `/${head}`
splitMap.get(head)?.push(i)
splits.shift()
}
else {
splitMap.set(head, [i])
// record the index of the label where the head first appears
firsts.push(i)
}
})
// update candidate of label which index appears in first array
firsts.forEach((i) => {
const label = labels[i]
const head = label.splits.shift()
label.candidate += label.candidate === '' ? head : `/${head}`
})
splitMap.forEach((value) => {
if (value.length === 1) {
const index = value[0]
labels[index].finished = true
}
})
splitMap.clear()
firsts.length = 0
if (finishedCount === labels.length) {
break
}
}
return result
}
export function createModuleLabelItem(module: string): ModuleLabelItem {
let raw = module
if (raw.includes('/node_modules/')) {
raw = module.split(/\/node_modules\//g).pop()!
}
const splits = raw.split(/\//g)
return {
raw,
splits,
candidate: '',
finished: false,
id: module,
}
}
export function getExternalModuleName(module: string) {
const label = createModuleLabelItem(module)
return label.raw
}
export function getImportDurationType(duration: number) {
if (duration >= 500) {
return 'danger'
}
if (duration >= 100) {
return 'warning'
}
}
export function getDurationClass(duration: number) {
const type = getImportDurationType(duration)
if (type === 'danger') {
return 'text-red'
}
if (type === 'warning') {
return 'text-orange'
}
}
export function getProjectNameColor(name: string | undefined) {
if (!name) {
return ''

View File

@ -9,6 +9,7 @@ import type { TestSpecification } from '../node/spec'
import type { Reporter } from '../node/types/reporter'
import type { LabelColor, ModuleGraphData, UserConsoleLog } from '../types/general'
import type {
ExternalResult,
TransformResultWithSource,
WebSocketEvents,
WebSocketHandlers,
@ -19,8 +20,10 @@ import { performance } from 'node:perf_hooks'
import { noop } from '@vitest/utils/helpers'
import { createBirpc } from 'birpc'
import { parse, stringify } from 'flatted'
import { isFileServingAllowed } from 'vite'
import { WebSocketServer } from 'ws'
import { API_PATH } from '../constants'
import { getTestFileEnvironment } from '../utils/environments'
import { getModuleGraph } from '../utils/graph'
import { stringifyReplace } from '../utils/serialization'
import { isValidApiRequest } from './check'
@ -91,18 +94,57 @@ export function setup(ctx: Vitest, _server?: ViteDevServer): void {
getResolvedProjectLabels(): { name: string; color?: LabelColor }[] {
return ctx.projects.map(p => ({ name: p.name, color: p.color }))
},
async getTransformResult(projectName: string, id, browser = false) {
const project = ctx.getProjectByName(projectName)
const result: TransformResultWithSource | null | undefined = browser
? await project.browser!.vite.transformRequest(id)
: await project.vite.transformRequest(id)
if (result) {
try {
result.source = result.source || (await fs.readFile(id, 'utf-8'))
}
catch {}
return result
async getExternalResult(moduleId: string, testFileTaskId: string) {
const testModule = ctx.state.getReportedEntityById(testFileTaskId) as TestModule | undefined
if (!testModule) {
return undefined
}
if (!isFileServingAllowed(testModule.project.vite.config, moduleId)) {
return undefined
}
const result: ExternalResult = {}
try {
result.source = await fs.readFile(moduleId, 'utf-8')
}
catch {}
return result
},
async getTransformResult(projectName: string, moduleId, testFileTaskId, browser = false) {
const project = ctx.getProjectByName(projectName)
const testModule = ctx.state.getReportedEntityById(testFileTaskId) as TestModule | undefined
if (!testModule || !isFileServingAllowed(project.vite.config, moduleId)) {
return
}
const environment = getTestFileEnvironment(project, testModule.moduleId, browser)
const moduleNode = environment?.moduleGraph.getModuleById(moduleId)
if (!environment || !moduleNode?.transformResult) {
return
}
const result: TransformResultWithSource = moduleNode.transformResult
try {
result.source = result.source || (moduleNode.file ? await fs.readFile(moduleNode.file, 'utf-8') : undefined)
}
catch {}
// TODO: store this in HTML reporter separetly
const transformDuration = ctx.state.metadata[projectName]?.duration[moduleNode.url]?.[0]
if (transformDuration != null) {
result.transformTime = transformDuration
}
try {
const diagnostic = await ctx.experimental_getSourceModuleDiagnostic(moduleId, testModule)
result.modules = diagnostic.modules
result.untrackedModules = diagnostic.untrackedModules
}
catch {}
return result
},
async getModuleGraph(project, id, browser): Promise<ModuleGraphData> {
return getModuleGraph(ctx, project, id, browser)

View File

@ -4,6 +4,7 @@ import type { BirpcReturn } from 'birpc'
import type { SerializedConfig } from '../runtime/config'
import type { SerializedTestSpecification } from '../runtime/types/utils'
import type { LabelColor, ModuleGraphData, UserConsoleLog } from '../types/general'
import type { ModuleDefinitionDurationsDiagnostic, UntrackedModuleDefinitionDiagnostic } from '../types/module-locations'
interface SourceMap {
file: string
@ -16,6 +17,10 @@ interface SourceMap {
toUrl: () => string
}
export interface ExternalResult {
source?: string
}
export interface TransformResultWithSource {
code: string
map: SourceMap | {
@ -25,6 +30,9 @@ export interface TransformResultWithSource {
deps?: string[]
dynamicDeps?: string[]
source?: string
transformTime?: number
modules?: ModuleDefinitionDurationsDiagnostic[]
untrackedModules?: UntrackedModuleDefinitionDiagnostic[]
}
export interface WebSocketHandlers {
@ -42,8 +50,13 @@ export interface WebSocketHandlers {
getTransformResult: (
projectName: string,
id: string,
testFileId: string,
browser?: boolean,
) => Promise<TransformResultWithSource | undefined>
getExternalResult: (
id: string,
testFileId: string,
) => Promise<ExternalResult | undefined>
readTestFile: (id: string) => Promise<string | null>
saveTestFile: (id: string, content: string) => Promise<void>
rerun: (files: string[], resetTestNamePattern?: boolean) => Promise<void>

View File

@ -12,6 +12,7 @@ import {
import { ancestor as walkAst } from 'acorn-walk'
import { relative } from 'pathe'
import { parseAst } from 'vite'
import { createIndexLocationsMap } from '../utils/base'
import { createDebugger } from '../utils/debugger'
interface ParsedFile extends File {
@ -265,7 +266,7 @@ function createFileTask(
file: null!,
}
file.file = file
const indexMap = createIndexMap(code)
const indexMap = createIndexLocationsMap(code)
const map = requestMap && new TraceMap(requestMap)
let lastSuite: ParsedSuite = file as any
const updateLatestSuite = (index: number) => {
@ -418,24 +419,6 @@ async function transformSSR(project: TestProject, filepath: string) {
return await project.vite.ssrTransform(request.code, request.map, filepath)
}
function createIndexMap(source: string) {
const map = new Map<number, { line: number; column: number }>()
let index = 0
let line = 1
let column = 1
for (const char of source) {
map.set(index++, { line, column })
if (char === '\n' || char === '\r\n') {
line++
column = 0
}
else {
column++
}
}
return map
}
function markDynamicTests(tasks: Task[]) {
for (const task of tasks) {
if (task.dynamic) {

View File

@ -34,7 +34,7 @@ export class FileSystemModuleCache {
private rootCache: string
private metadataFilePath: string
private version = '1.0.0-beta.2'
private version = '1.0.0-beta.3'
private fsCacheRoots = new WeakMap<ResolvedConfig, string>()
private fsEnvironmentHashMap = new WeakMap<DevEnvironment, string>()
private fsCacheKeyGenerators = new Set<CacheKeyIdGenerator>()
@ -110,6 +110,7 @@ export class FileSystemModuleCache {
file: meta.file,
code,
importers: meta.importers,
importedUrls: meta.importedUrls,
mappings: meta.mappings,
}
}
@ -118,6 +119,7 @@ export class FileSystemModuleCache {
cachedFilePath: string,
fetchResult: T,
importers: string[] = [],
importedUrls: string[] = [],
mappings: boolean = false,
): Promise<void> {
if ('code' in fetchResult) {
@ -126,8 +128,9 @@ export class FileSystemModuleCache {
id: fetchResult.id,
url: fetchResult.url,
importers,
importedUrls,
mappings,
} satisfies Omit<FetchResult, 'code' | 'invalidate'>
} satisfies Omit<CachedInlineModuleMeta, 'code'>
debugFs?.(`${c.yellow('[write]')} ${fetchResult.id} is cached in ${cachedFilePath}`)
await atomicWriteFile(cachedFilePath, `${fetchResult.code}${cacheComment}${this.toBase64(result)}`)
}
@ -365,6 +368,7 @@ export interface CachedInlineModuleMeta {
code: string
importers: string[]
mappings: boolean
importedUrls: string[]
}
/**

View File

@ -775,6 +775,9 @@ export const cliOptionsConfig: VitestCLIOptions = {
},
fsModuleCachePath: null,
openTelemetry: null,
printImportBreakdown: {
description: 'Print import breakdown after the summary. If the reporter doesn\'t support summary, this will have no effect. Note that UI\'s "Module Graph" tab always has an import breakdown.',
},
},
},
// disable CLI options

View File

@ -132,6 +132,7 @@ export function serializeConfig(project: TestProject): SerializedConfig {
: project._serializedDefines || '',
experimental: {
fsModuleCache: config.experimental.fsModuleCache ?? false,
printImportBreakdown: config.experimental.printImportBreakdown,
},
}
}

View File

@ -5,6 +5,7 @@ import type { ViteDevServer } from 'vite'
import type { ModuleRunner } from 'vite/module-runner'
import type { SerializedCoverageConfig } from '../runtime/config'
import type { ArgumentsType, ProvidedContext, UserConsoleLog } from '../types/general'
import type { SourceModuleDiagnostic, SourceModuleLocations } from '../types/module-locations'
import type { CliOptions } from './cli/cli-api'
import type { VitestFetchFunction } from './environments/fetchModule'
import type { ProcessPool } from './pool'
@ -35,6 +36,7 @@ import { createFetchModuleFunction } from './environments/fetchModule'
import { ServerModuleRunner } from './environments/serverRunner'
import { FilesNotFoundError } from './errors'
import { Logger } from './logger'
import { collectModuleDurationsDiagnostic, collectSourceModulesLocations } from './module-diagnostic'
import { VitestPackageInstaller } from './packageInstaller'
import { createPool } from './pool'
import { TestProject } from './project'
@ -873,6 +875,46 @@ export class Vitest {
})
}
/**
* Returns module's diagnostic. If `testModule` is not provided, `selfTime` and `totalTime` will be aggregated across all tests.
*
* If the module was not transformed or executed, the diagnostic will be empty.
* @experimental
* @see {@link https://vitest.dev/api/advanced/vitest#getsourcemodulediagnostic}
*/
public async experimental_getSourceModuleDiagnostic(moduleId: string, testModule?: TestModule): Promise<SourceModuleDiagnostic> {
if (testModule) {
const viteEnvironment = testModule.viteEnvironment
// if there is no viteEnvironment, it means the file did not run yet
if (!viteEnvironment) {
return { modules: [], untrackedModules: [] }
}
const moduleLocations = await collectSourceModulesLocations(moduleId, viteEnvironment.moduleGraph)
return collectModuleDurationsDiagnostic(moduleId, this.state, moduleLocations, testModule)
}
const environments = this.projects.flatMap((p) => {
return Object.values(p.vite.environments)
})
const aggregatedLocationsResult = await Promise.all(
environments.map(environment =>
collectSourceModulesLocations(moduleId, environment.moduleGraph),
),
)
return collectModuleDurationsDiagnostic(
moduleId,
this.state,
aggregatedLocationsResult.reduce<SourceModuleLocations>((acc, locations) => {
if (locations) {
acc.modules.push(...locations.modules)
acc.untracked.push(...locations.untracked)
}
return acc
}, { modules: [], untracked: [] }),
)
}
public async experimental_parseSpecifications(specifications: TestSpecification[], options?: {
/** @default os.availableParallelism() */
concurrency?: number

View File

@ -24,7 +24,6 @@ class ModuleFetcher {
private resolver: VitestResolver,
private config: ResolvedConfig,
private fsCache: FileSystemModuleCache,
private traces: Traces,
private tmpProjectDir: string,
) {
this.fsCacheEnabled = config.experimental?.fsModuleCache === true
@ -125,12 +124,23 @@ class ModuleFetcher {
const result = await this.fetchAndProcess(environment, url, importer, moduleGraphModule, options)
const importers = this.getSerializedDependencies(moduleGraphModule)
const importedUrls = this.getSerializedImports(moduleGraphModule)
const map = moduleGraphModule.transformResult?.map
const mappings = map && !('version' in map) && map.mappings === ''
return this.cacheResult(result, cachePath, importers, !!mappings)
return this.cacheResult(result, cachePath, importers, importedUrls, !!mappings)
}
// we need this for UI to be able to show a module graph
private getSerializedImports(node: EnvironmentModuleNode): string[] {
const imports: string[] = []
node.importedModules.forEach((importer) => {
imports.push(importer.url)
})
return imports
}
// we need this for the watcher to be able to find the related test file
private getSerializedDependencies(node: EnvironmentModuleNode): string[] {
const dependencies: string[] = []
node.importers.forEach((importer) => {
@ -259,6 +269,13 @@ class ModuleFetcher {
}
})
await Promise.all(cachedModule.importedUrls.map(async (url) => {
const moduleNode = await environment.moduleGraph.ensureEntryFromUrl(url).catch(() => null)
if (moduleNode) {
moduleGraphModule.importedModules.add(moduleNode)
}
}))
return {
cached: true as const,
file: cachedModule.file,
@ -293,6 +310,7 @@ class ModuleFetcher {
result: FetchResult,
cachePath: string,
importers: string[] = [],
importedUrls: string[] = [],
mappings = false,
): Promise<FetchResult | FetchCachedFileSystemResult> {
const returnResult = 'code' in result
@ -305,7 +323,7 @@ class ModuleFetcher {
}
const savePromise = this.fsCache
.saveCachedModule(cachePath, result, importers, mappings)
.saveCachedModule(cachePath, result, importers, importedUrls, mappings)
.then(() => result)
.finally(() => {
saveCachePromises.delete(cachePath)
@ -349,7 +367,7 @@ export function createFetchModuleFunction(
traces: Traces,
tmpProjectDir: string,
): VitestFetchFunction {
const fetcher = new ModuleFetcher(resolver, config, fsCache, traces, tmpProjectDir)
const fetcher = new ModuleFetcher(resolver, config, fsCache, tmpProjectDir)
return async (url, importer, environment, cacheFs, options, otelCarrier) => {
await traces.waitInit()
const context = otelCarrier

View File

@ -0,0 +1,351 @@
import type { ImportDuration } from '@vitest/runner'
import type { EnvironmentModuleGraph, TransformResult } from 'vite'
import type {
ModuleDefinitionDiagnostic,
ModuleDefinitionDurationsDiagnostic,
ModuleDefinitionLocation,
SourceModuleDiagnostic,
SourceModuleLocations,
UntrackedModuleDefinitionDiagnostic,
} from '../types/module-locations'
import type { TestModule } from './reporters/reported-tasks'
import type { StateManager } from './state'
import { originalPositionFor, TraceMap } from '@jridgewell/trace-mapping'
import { createIndexLocationsMap } from '../utils/base'
// this function recieves the module diagnostic with the location of imports
// and populates it with collected import durations; the duration is injected
// only if the current module is the one that imported the module
// if testModule is not defined, then Vitest aggregates durations of ALL collected test modules
export function collectModuleDurationsDiagnostic(
moduleId: string,
state: StateManager,
moduleDiagnostic: SourceModuleLocations | undefined,
testModule?: TestModule,
): SourceModuleDiagnostic {
if (!moduleDiagnostic) {
return { modules: [], untrackedModules: [] }
}
const modules: ModuleDefinitionDurationsDiagnostic[] = []
const modulesById: Record<string, {
selfTime: number
totalTime: number
transformTime?: number
external?: boolean
importer?: string
}> = {}
const allModules = [...moduleDiagnostic.modules, ...moduleDiagnostic.untracked]
const visitedByFiles: Record<string, Set<string>> = {}
// this aggregates the times for _ALL_ tests if testModule is not passed
// so if the module was imported in separate tests, the time will be accumulated
for (const files of (testModule ? [[testModule.task]] : state.filesMap.values())) {
for (const file of files) {
const importDurations = file.importDurations
if (!importDurations) {
continue
}
const currentModule = state.getReportedEntity(file) as TestModule | undefined
if (!currentModule) {
continue
}
const visitedKey = currentModule.project.config.isolate === false ? 'non-isolate' : file.id
if (!visitedByFiles[visitedKey]) {
visitedByFiles[visitedKey] = new Set()
}
const visited = visitedByFiles[visitedKey]
allModules.forEach(({ resolvedId, resolvedUrl }) => {
const durations = importDurations[resolvedId]
// do not accumulate if module was already visited by suite (or suites in non-isolate mode)
if (!durations || visited.has(resolvedId)) {
return
}
const importer = getModuleImporter(moduleId, durations, currentModule)
modulesById[resolvedId] ??= {
selfTime: 0,
totalTime: 0,
transformTime: 0,
external: durations.external,
importer,
}
// only track if the current module imported this module,
// otherwise it was imported instantly because it's cached
if (importer === moduleId) {
visited.add(resolvedId)
modulesById[resolvedId].selfTime += durations.selfTime
modulesById[resolvedId].totalTime += durations.totalTime
// don't aggregate
modulesById[resolvedId].transformTime = state.metadata[currentModule.project.name]?.duration[resolvedUrl]?.[0]
}
})
}
}
// if module was imported twice in the same file,
// show only one time - the second should be shown as 0
const visitedInFile = new Set<string>()
moduleDiagnostic.modules.forEach((diagnostic) => {
const durations = modulesById[diagnostic.resolvedId]
if (!durations) {
return
}
if (visitedInFile.has(diagnostic.resolvedId)) {
modules.push({
...diagnostic,
selfTime: 0,
totalTime: 0,
transformTime: 0,
external: durations.external,
importer: durations.importer,
})
}
else {
visitedInFile.add(diagnostic.resolvedId)
modules.push({
...diagnostic,
...durations,
})
}
})
const untracked: UntrackedModuleDefinitionDiagnostic[] = []
moduleDiagnostic.untracked.forEach((diagnostic) => {
const durations = modulesById[diagnostic.resolvedId]
if (!durations) {
return
}
if (visitedInFile.has(diagnostic.resolvedId)) {
untracked.push({
selfTime: 0,
totalTime: 0,
transformTime: 0,
external: durations.external,
importer: durations.importer,
resolvedId: diagnostic.resolvedId,
resolvedUrl: diagnostic.resolvedUrl,
url: diagnostic.rawUrl,
})
}
else {
visitedInFile.add(diagnostic.resolvedId)
untracked.push({
...durations,
resolvedId: diagnostic.resolvedId,
resolvedUrl: diagnostic.resolvedUrl,
url: diagnostic.rawUrl,
})
}
})
return {
modules,
untrackedModules: untracked,
}
}
function getModuleImporter(moduleId: string, durations: ImportDuration, testModule: TestModule): string | undefined {
if (durations.importer === moduleId) {
return moduleId
}
if (!durations.importer) {
if (moduleId === testModule.moduleId) {
return testModule.moduleId
}
const setupFiles = testModule.project.config.setupFiles
return setupFiles.includes(moduleId)
? moduleId
: durations.importer
}
return durations.importer
}
// the idea of this is very simple
// it parses the source code to extract import/export statements
// it parses SSR transformed file to extract __vite_ssr_import__ and __vite_ssr_dynamic_import__
// it combines the two by looking at the original positions of SSR primitives
// in the end, we are able to return a list of modules that were imported by this module
// mapped to their IDs in Vite's module graph
export async function collectSourceModulesLocations(
moduleId: string,
moduleGraph: EnvironmentModuleGraph,
): Promise<SourceModuleLocations | undefined> {
const transformResult = moduleGraph.getModuleById(moduleId)?.transformResult
if (!transformResult || !transformResult.ssr) {
return
}
const map = transformResult.map
if (!map || !('version' in map) || !map.sources.length) {
return
}
const sourceImports = map.sources.reduce<Record<string, Map<string, SourceStaticImport>>>(
(acc, sourceId, index) => {
const source = map.sourcesContent?.[index]
if (source != null) {
acc[sourceId] = parseSourceImportsAndExports(source)
}
return acc
},
{},
)
const transformImports = await parseTransformResult(moduleGraph, transformResult)
const traceMap = map && 'version' in map && new TraceMap(map as any)
const modules: Record<string, ModuleDefinitionDiagnostic[]> = {}
const untracked: ModuleDefinitionDiagnostic[] = []
transformImports.forEach((row) => {
const original = traceMap && originalPositionFor(traceMap, row.start)
if (original && original.source != null) {
// if there are several at the same position, this is a bug
// probably caused by import.meta.glob imports returning incorrect positions
// all the new import.meta.glob imports come first, so only the last module on this line is correct
const sourceImport = sourceImports[original.source].get(`${original.line}:${original.column}`)
if (sourceImport) {
if (modules[sourceImport.rawUrl]) {
// remove imports with a different resolvedId
const differentImports = modules[sourceImport.rawUrl].filter(d => d.resolvedId !== row.resolvedId)
untracked.push(...differentImports)
modules[sourceImport.rawUrl] = modules[sourceImport.rawUrl].filter(d => d.resolvedId === row.resolvedId)
}
modules[sourceImport.rawUrl] ??= []
modules[sourceImport.rawUrl].push({
start: sourceImport.start,
end: sourceImport.end,
startIndex: sourceImport.startIndex,
endIndex: sourceImport.endIndex,
rawUrl: sourceImport.rawUrl,
resolvedId: row.resolvedId,
resolvedUrl: row.resolvedUrl,
})
}
}
})
return {
modules: Object.values(modules).flat(),
untracked,
}
}
interface SourceStaticImport {
start: ModuleDefinitionLocation
end: ModuleDefinitionLocation
startIndex: number
endIndex: number
rawUrl: string
}
function fillSourcesMap(
syntax: 'import' | 'export',
sourcesMap: Map<string, SourceStaticImport>,
source: string,
indexMap: Map<number, ModuleDefinitionLocation>,
) {
const splitSeparator = `${syntax} `
const splitSources = source.split(splitSeparator)
const chunks: {
chunk: string
startIndex: number
}[] = []
let index = 0
for (const chunk of splitSources) {
chunks.push({
chunk,
startIndex: index,
})
index += chunk.length + splitSeparator.length
}
chunks.forEach(({ chunk, startIndex }) => {
const normalized = chunk.replace(/'/g, '"')
const startQuoteIdx = normalized.indexOf('"')
if (startQuoteIdx === -1) {
return
}
const endQuoteIdx = normalized.indexOf('"', startQuoteIdx + 1)
if (endQuoteIdx === -1) {
return
}
const staticSyntax = {
startIndex: startIndex + startQuoteIdx,
endIndex: startIndex + endQuoteIdx + 1,
start: indexMap.get(startIndex + startQuoteIdx)!,
end: indexMap.get(startIndex + endQuoteIdx + 1)!,
rawUrl: normalized.slice(startQuoteIdx + 1, endQuoteIdx),
}
// -7 to include "import "
for (let i = startIndex - 7; i < staticSyntax.endIndex; i++) {
const location = indexMap.get(i)!
if (location) {
sourcesMap.set(`${location.line}:${location.column}`, staticSyntax)
}
}
})
}
// this function tries to parse ESM static import and export statements from
// the source. if the source is not JS/TS, but supports static ESM syntax,
// then this will also find them because it' only checks the strings, it doesn't parse the AST
function parseSourceImportsAndExports(source: string): Map<string, SourceStaticImport> {
if (!source.includes('import ') && !source.includes('export ')) {
return new Map()
}
const sourcesMap = new Map<string, SourceStaticImport>()
const indexMap = createIndexLocationsMap(source)
fillSourcesMap('import', sourcesMap, source, indexMap)
fillSourcesMap('export', sourcesMap, source, indexMap)
return sourcesMap
}
async function parseTransformResult(moduleGraph: EnvironmentModuleGraph, transformResult: TransformResult) {
const code = transformResult.code
const regexp = /(?:__vite_ssr_import__|__vite_ssr_dynamic_import__)\("([^"]+)"/g
const lineColumnMap = createIndexLocationsMap(code)
const importPositions: {
raw: string
startIndex: number
endIndex: number
}[] = []
let match: RegExpMatchArray | null
// eslint-disable-next-line no-cond-assign
while (match = regexp.exec(code)) {
const startIndex = match.index!
const endIndex = match.index! + match[0].length - 1 // 1 is "
importPositions.push({ raw: match[1], startIndex, endIndex })
}
const results = await Promise.all(importPositions.map(async ({ startIndex, endIndex, raw }) => {
const position = lineColumnMap.get(startIndex)!
const endPosition = lineColumnMap.get(endIndex)!
const moduleNode = await moduleGraph.getModuleByUrl(raw)
if (!position || !endPosition || !moduleNode || !moduleNode.id) {
return
}
return {
resolvedId: moduleNode.id,
resolvedUrl: moduleNode.url,
start: position,
end: endPosition,
startIndex,
endIndex,
}
}))
return results.filter(n => n != null)
}

View File

@ -11,6 +11,7 @@ import { toArray } from '@vitest/utils/helpers'
import { parseStacktrace } from '@vitest/utils/source-map'
import { relative } from 'pathe'
import c from 'tinyrainbow'
import { groupBy } from '../../utils/base'
import { isTTY } from '../../utils/env'
import { hasFailedSnapshot } from '../../utils/tasks'
import { F_CHECK, F_DOWN_RIGHT, F_POINTER } from './renderers/figures'
@ -606,9 +607,117 @@ export abstract class BaseReporter implements Reporter {
}
}
if (this.ctx.config.experimental.printImportBreakdown) {
this.printImportsBreakdown()
}
this.log()
}
private printImportsBreakdown() {
const testModules = this.ctx.state.getTestModules()
interface ImportEntry {
importedModuleId: string
selfTime: number
external?: boolean
totalTime: number
testModule: TestModule
}
const allImports: ImportEntry[] = []
for (const testModule of testModules) {
const diagnostic = testModule.diagnostic()
const importDurations = diagnostic.importDurations
for (const filePath in importDurations) {
const duration = importDurations[filePath]
allImports.push({
importedModuleId: filePath,
testModule,
selfTime: duration.selfTime,
totalTime: duration.totalTime,
external: duration.external,
})
}
}
if (allImports.length === 0) {
return
}
const sortedImports = allImports.sort((a, b) => b.totalTime - a.totalTime)
const maxTotalTime = sortedImports[0].totalTime
const topImports = sortedImports.slice(0, 10)
const totalSelfTime = allImports.reduce((sum, imp) => sum + imp.selfTime, 0)
const totalTotalTime = allImports.reduce((sum, imp) => sum + imp.totalTime, 0)
const slowestImport = sortedImports[0]
this.log()
this.log(c.bold('Import Duration Breakdown') + c.dim(' (ordered by Total Time) (Top 10)'))
// if there are multiple files, it's highly possible that some of them will import the same large file
// we group them to show the distinction between those files more easily
// Import Duration Breakdown (ordered by Total Time) (Top 10)
// .../fields/FieldFile/__tests__/FieldFile.spec.ts self: 7ms total: 1.01s ████████████████████
// ↳ tests/support/components/index.ts self: 0ms total: 861ms █████████████████░░░
// ↳ tests/support/components/renderComponent.ts self: 59ms total: 861ms █████████████████░░░
// ...s__/apps/desktop/form-updater.desktop.spec.ts self: 8ms total: 991ms ████████████████████
// ...sts__/apps/mobile/form-updater.mobile.spec.ts self: 11ms total: 990ms ████████████████████
// shared/components/Form/__tests__/Form.spec.ts self: 5ms total: 988ms ████████████████████
// ↳ tests/support/components/index.ts self: 0ms total: 935ms ███████████████████░
// ↳ tests/support/components/renderComponent.ts self: 61ms total: 935ms ███████████████████░
// ...ditor/features/link/__test__/LinkForm.spec.ts self: 7ms total: 972ms ███████████████████░
// ↳ tests/support/components/renderComponent.ts self: 56ms total: 936ms ███████████████████░
const groupedImports = Object.entries(
groupBy(topImports, i => i.testModule.id),
// the first one is always the highest because the modules are already sorted
).sort(([, imps1], [, imps2]) => imps2[0].totalTime - imps1[0].totalTime)
for (const [_, group] of groupedImports) {
group.forEach((imp, index) => {
const barWidth = 20
const filledWidth = Math.round((imp.totalTime / maxTotalTime) * barWidth)
const bar = c.cyan('█'.repeat(filledWidth)) + c.dim('░'.repeat(barWidth - filledWidth))
// only show the arrow if there is more than 1 group
const pathDisplay = this.ellipsisPath(imp.importedModuleId, imp.external, groupedImports.length > 1 && index > 0)
this.log(
`${pathDisplay} ${c.dim('self:')} ${this.importDurationTime(imp.selfTime)} ${c.dim('total:')} ${this.importDurationTime(imp.totalTime)} ${bar}`,
)
})
}
this.log()
this.log(c.dim('Total imports: ') + allImports.length)
this.log(c.dim('Slowest import (total-time): ') + formatTime(slowestImport.totalTime))
this.log(c.dim('Total import time (self/total): ') + formatTime(totalSelfTime) + c.dim(' / ') + formatTime(totalTotalTime))
}
private importDurationTime(duration: number) {
const color = duration >= 500 ? c.red : duration >= 100 ? c.yellow : (c: string) => c
return color(formatTime(duration).padStart(6))
}
private ellipsisPath(path: string, external: boolean | undefined, nested: boolean) {
const pathDisplay = this.relative(path)
const color = external ? c.magenta : (c: string) => c
const slicedPath = pathDisplay.slice(-44)
let title = ''
if (pathDisplay.length > slicedPath.length) {
title += '...'
}
if (nested) {
title = ` ${F_DOWN_RIGHT} ${title}`
}
title += slicedPath
return color(title.padEnd(50))
}
private printErrorsSummary(files: File[], errors: unknown[]) {
const suites = getSuites(files)
const tests = getTests(files)

View File

@ -9,6 +9,7 @@ import type {
TestArtifact,
} from '@vitest/runner'
import type { SerializedError, TestError } from '@vitest/utils'
import type { DevEnvironment } from 'vite'
import type { TestProject } from '../project'
class ReportedTaskImplementation {
@ -440,6 +441,13 @@ export class TestModule extends SuiteImplementation {
declare public readonly location: undefined
public readonly type = 'module'
/**
* The Vite environment that processes files on the server.
*
* Can be empty if test module did not run yet.
*/
public readonly viteEnvironment: DevEnvironment | undefined
/**
* This is usually an absolute UNIX file path.
* It can be a virtual ID if the file is not on the disk.
@ -457,6 +465,12 @@ export class TestModule extends SuiteImplementation {
super(task, project)
this.moduleId = task.filepath
this.relativeModuleId = task.name
if (task.viteEnvironment === '__browser__') {
this.viteEnvironment = project.browser?.vite.environments.client
}
else if (typeof task.viteEnvironment === 'string') {
this.viteEnvironment = project.vite.environments[task.viteEnvironment]
}
}
/**

View File

@ -11,7 +11,8 @@ import { isWindows } from '../utils/env'
export class VitestResolver {
public readonly options: ExternalizeOptions
private externalizeCache = new Map<string, Promise<string | false>>()
private externalizeConcurrentCache = new Map<string, Promise<string | false | undefined>>()
private externalizeCache = new Map<string, string | false | undefined>()
constructor(cacheDir: string, config: ResolvedConfig) {
// sorting to make cache consistent
@ -38,8 +39,26 @@ export class VitestResolver {
}
}
public shouldExternalize(file: string): Promise<string | false | undefined> {
return shouldExternalize(normalizeId(file), this.options, this.externalizeCache)
public wasExternalized(file: string): string | false {
const normalizedFile = normalizeId(file)
if (!this.externalizeCache.has(normalizedFile)) {
return false
}
return this.externalizeCache.get(normalizedFile) ?? false
}
public async shouldExternalize(file: string): Promise<string | false | undefined> {
const normalizedFile = normalizeId(file)
if (this.externalizeCache.has(normalizedFile)) {
return this.externalizeCache.get(normalizedFile)!
}
return shouldExternalize(normalizeId(file), this.options, this.externalizeConcurrentCache).then((result) => {
this.externalizeCache.set(normalizedFile, result)
return result
}).finally(() => {
this.externalizeConcurrentCache.delete(normalizedFile)
})
}
}

View File

@ -848,6 +848,12 @@ export interface InlineConfig {
enabled: boolean
sdkPath?: string
}
/**
* Show imports (top 10) that take a long time.
*
* Enabling this will also show a breakdown by default in UI, but you can always press a button to toggle it.
*/
printImportBreakdown?: boolean
}
}

View File

@ -1,8 +1,28 @@
import type { SerializedTestSpecification } from '../runtime/types/utils'
import type {
ModuleDefinitionDiagnostic,
ModuleDefinitionDurationsDiagnostic,
ModuleDefinitionLocation,
SourceModuleDiagnostic,
SourceModuleLocations,
UntrackedModuleDefinitionDiagnostic,
} from '../types/module-locations'
import '../types/global'
// eslint-disable-next-line ts/no-namespace
export declare namespace Experimental {
export {
ModuleDefinitionDiagnostic,
ModuleDefinitionDurationsDiagnostic,
ModuleDefinitionLocation,
SourceModuleDiagnostic,
SourceModuleLocations,
UntrackedModuleDefinitionDiagnostic,
}
}
export type {
ExternalResult,
TransformResultWithSource,
WebSocketEvents,
WebSocketHandlers,
@ -39,6 +59,7 @@ export { expectTypeOf } from '../typecheck/expectTypeOf'
export type { ExpectTypeOf } from '../typecheck/expectTypeOf'
export type { BrowserTesterOptions } from '../types/browser'
// export type * as Experimental from '../types/experimental'
export type {
AfterSuiteRunMeta,
LabelColor,

View File

@ -119,6 +119,7 @@ export interface SerializedConfig {
serializedDefines: string
experimental: {
fsModuleCache: boolean
printImportBreakdown: boolean | undefined
}
}

View File

@ -8,6 +8,9 @@ export interface ModuleExecutionInfoEntry {
/** The time that was spent executing the module itself and externalized imports. */
selfTime: number
external?: boolean
importer?: string
}
/** Stack to track nested module execution for self-time calculation. */
@ -22,12 +25,18 @@ export type ExecutionStack = Array<{
subImportTime: number
}>
export interface ExecutionInfoOptions {
startOffset: number
external?: boolean
importer?: string
}
const performanceNow = performance.now.bind(performance)
export class ModuleDebug {
private executionStack: ExecutionStack = []
startCalculateModuleExecutionInfo(filename: string, startOffset: number): () => ModuleExecutionInfoEntry {
startCalculateModuleExecutionInfo(filename: string, options: ExecutionInfoOptions): () => ModuleExecutionInfoEntry {
const startTime = performanceNow()
this.executionStack.push({
@ -52,7 +61,9 @@ export class ModuleDebug {
}
return {
startOffset,
startOffset: options.startOffset,
external: options.external,
importer: options.importer,
duration,
selfTime,
}

View File

@ -5,10 +5,11 @@ import type {
ModuleRunnerContext,
ModuleRunnerImportMeta,
} from 'vite/module-runner'
import type { VitestEvaluatedModules } from './evaluatedModules'
import type { ModuleExecutionInfo } from './moduleDebug'
import type { VitestVmOptions } from './moduleRunner'
import { createRequire, isBuiltin } from 'node:module'
import { pathToFileURL } from 'node:url'
import { fileURLToPath, pathToFileURL } from 'node:url'
import vm from 'node:vm'
import { isAbsolute } from 'pathe'
import {
@ -24,6 +25,7 @@ import { ModuleDebug } from './moduleDebug'
const isWindows = process.platform === 'win32'
export interface VitestModuleEvaluatorOptions {
evaluatedModules?: VitestEvaluatedModules
interopDefault?: boolean | undefined
moduleExecutionInfo?: ModuleExecutionInfo
getCurrentTestFilepath?: () => string | undefined
@ -51,6 +53,7 @@ export class VitestModuleEvaluator implements ModuleEvaluator {
private debug = new ModuleDebug()
private _otel: Traces
private _evaluatedModules?: VitestEvaluatedModules
constructor(
vmOptions?: VitestVmOptions | undefined,
@ -59,6 +62,7 @@ export class VitestModuleEvaluator implements ModuleEvaluator {
this._otel = options.traces || new Traces({ enabled: false })
this.vm = vmOptions
this.stubs = getDefaultRequestStubs(vmOptions?.context)
this._evaluatedModules = options.evaluatedModules
if (options.compiledFunctionArgumentsNames) {
this.compiledFunctionArgumentsNames = options.compiledFunctionArgumentsNames
}
@ -102,7 +106,18 @@ export class VitestModuleEvaluator implements ModuleEvaluator {
const file = this.convertIdToImportUrl(id)
const finishModuleExecutionInfo = this.debug.startCalculateModuleExecutionInfo(file, 0)
// this will always be 1 element because it's cached after load
const importers = this._evaluatedModules?.getModuleById(id)?.importers
const importer = importers?.values().next().value
const filename = id.startsWith('file://') ? fileURLToPath(id) : id
const finishModuleExecutionInfo = this.debug.startCalculateModuleExecutionInfo(
filename,
{
startOffset: 0,
external: true,
importer,
},
)
const namespace = await this._otel.$(
'vitest.module.external',
{
@ -112,7 +127,7 @@ export class VitestModuleEvaluator implements ModuleEvaluator {
? this.vm.externalModulesExecutor.import(file)
: import(file),
).finally(() => {
finishModuleExecutionInfo()
this.options.moduleExecutionInfo?.set(filename, finishModuleExecutionInfo())
})
if (!this.shouldInterop(file, namespace)) {
@ -295,7 +310,12 @@ export class VitestModuleEvaluator implements ModuleEvaluator {
columnOffset: -codeDefinition.length,
}
const finishModuleExecutionInfo = this.debug.startCalculateModuleExecutionInfo(options.filename, codeDefinition.length)
// this will always be 1 element because it's cached after load
const importer = module.importers.values().next().value
const finishModuleExecutionInfo = this.debug.startCalculateModuleExecutionInfo(options.filename, {
startOffset: codeDefinition.length,
importer,
})
try {
const initModule = this.vm

View File

@ -64,6 +64,7 @@ export function startVitestModuleRunner(options: ContextModuleRunnerOptions): Vi
vm,
{
traces,
evaluatedModules: options.evaluatedModules,
get moduleExecutionInfo() {
return state().moduleExecutionInfo
},

View File

@ -38,8 +38,12 @@ export class VitestTestRunner implements VitestRunner {
public pool: string = this.workerState.ctx.pool
private _otel!: Traces
public viteEnvironment: string
constructor(public config: SerializedConfig) {}
constructor(public config: SerializedConfig) {
const environment = this.workerState.environment
this.viteEnvironment = environment.viteEnvironment || environment.name
}
importFile(filepath: string, source: VitestRunnerImportSource): unknown {
if (source === 'setup') {
@ -226,10 +230,12 @@ export class VitestTestRunner implements VitestRunner {
const importDurations: Record<string, ImportDuration> = {}
const entries = this.workerState.moduleExecutionInfo?.entries() || []
for (const [filepath, { duration, selfTime }] of entries) {
for (const [filepath, { duration, selfTime, external, importer }] of entries) {
importDurations[normalize(filepath)] = {
selfTime,
totalTime: duration,
external,
importer,
}
}

View File

@ -13,10 +13,10 @@ import { eachMapping, generatedPositionFor, TraceMap } from '@jridgewell/trace-m
import { basename, join, resolve } from 'pathe'
import { x } from 'tinyexec'
import { distDir } from '../paths'
import { createLocationsIndexMap } from '../utils/base'
import { convertTasksToEvents } from '../utils/tasks'
import { collectTests } from './collect'
import { getRawErrsMapFromTsCompile } from './parse'
import { createIndexMap } from './utils'
export class TypeCheckError extends Error {
name = 'TypeCheckError'
@ -146,7 +146,7 @@ export class Typechecker {
]
// has no map for ".js" files that use // @ts-check
const traceMap = (map && new TraceMap(map as any))
const indexMap = createIndexMap(parsed)
const indexMap = createLocationsIndexMap(parsed)
const markState = (task: Task, state: TaskState) => {
task.result = {
state:

View File

@ -1,17 +0,0 @@
export function createIndexMap(source: string): Map<string, number> {
const map = new Map<string, number>()
let index = 0
let line = 1
let column = 1
for (const char of source) {
map.set(`${line}:${column}`, index++)
if (char === '\n' || char === '\r\n') {
line++
column = 0
}
else {
column++
}
}
return map
}

View File

@ -0,0 +1,43 @@
export interface ModuleDefinitionLocation {
line: number
column: number
}
export interface SourceModuleLocations {
modules: ModuleDefinitionDiagnostic[]
untracked: ModuleDefinitionDiagnostic[]
}
export interface ModuleDefinitionDiagnostic {
start: ModuleDefinitionLocation
end: ModuleDefinitionLocation
startIndex: number
endIndex: number
rawUrl: string
resolvedUrl: string
resolvedId: string
}
export interface ModuleDefinitionDurationsDiagnostic extends ModuleDefinitionDiagnostic {
selfTime: number
totalTime: number
transformTime?: number
external?: boolean
importer?: string
}
export interface UntrackedModuleDefinitionDiagnostic {
url: string
resolvedId: string
resolvedUrl: string
selfTime: number
totalTime: number
transformTime?: number
external?: boolean
importer?: string
}
export interface SourceModuleDiagnostic {
modules: ModuleDefinitionDurationsDiagnostic[]
untrackedModules: UntrackedModuleDefinitionDiagnostic[]
}

View File

@ -1,3 +1,5 @@
import type { ModuleDefinitionLocation } from '../types/module-locations'
export { getCallLastIndex, nanoid, notNullish } from '@vitest/utils/helpers'
export function groupBy<T, K extends string | number | symbol>(
@ -38,3 +40,39 @@ export function wildcardPatternToRegExp(pattern: string): RegExp {
return new RegExp(`^${regexp}`, 'i')
}
export function createIndexLocationsMap(source: string): Map<number, ModuleDefinitionLocation> {
const map = new Map<number, ModuleDefinitionLocation>()
let index = 0
let line = 1
let column = 1
for (const char of source) {
map.set(index++, { line, column })
if (char === '\n' || char === '\r\n') {
line++
column = 0
}
else {
column++
}
}
return map
}
export function createLocationsIndexMap(source: string): Map<string, number> {
const map = new Map<string, number>()
let index = 0
let line = 1
let column = 1
for (const char of source) {
map.set(`${line}:${column}`, index++)
if (char === '\n' || char === '\r\n') {
line++
column = 0
}
else {
column++
}
}
return map
}

View File

@ -0,0 +1,20 @@
import type { DevEnvironment } from 'vite'
import type { TestProject } from '../node/project'
export function getTestFileEnvironment(project: TestProject, testFile: string, browser = false): DevEnvironment | undefined {
let environment: DevEnvironment | undefined
if (browser) {
environment = project.browser?.vite.environments.client
}
else {
for (const name in project.vite.environments) {
const env = project.vite.environments[name]
if (env.moduleGraph.getModuleById(testFile)) {
environment = env
break
}
}
}
return environment
}

View File

@ -1,11 +1,12 @@
import type { ModuleNode } from 'vite'
import type { EnvironmentModuleNode } from 'vite'
import type { Vitest } from '../node/core'
import type { ModuleGraphData } from '../types/general'
import { getTestFileEnvironment } from './environments'
export async function getModuleGraph(
ctx: Vitest,
projectName: string,
id: string,
testFilePath: string,
browser = false,
): Promise<ModuleGraphData> {
const graph: Record<string, string[]> = {}
@ -14,46 +15,59 @@ export async function getModuleGraph(
const project = ctx.getProjectByName(projectName)
async function get(mod?: ModuleNode, seen = new Map<ModuleNode, string>()) {
const environment = getTestFileEnvironment(project, testFilePath, browser)
if (!environment) {
throw new Error(`Cannot find environment for ${testFilePath}`)
}
const seen = new Map<EnvironmentModuleNode, string>()
function get(mod?: EnvironmentModuleNode) {
if (!mod || !mod.id) {
return
}
if (mod.id === '\0vitest/browser') {
if (
mod.id === '\0vitest/browser'
// the export helper is injected in all vue files
// so the module graph becomes too bouncy
|| mod.id.includes('plugin-vue:export-helper')
) {
return
}
if (seen.has(mod)) {
return seen.get(mod)
}
let id = clearId(mod.id)
const id = clearId(mod.id)
seen.set(mod, id)
// TODO: how to know if it was rewritten(?) - what is rewritten?
const rewrote = browser
? mod.file?.includes(project.browser!.vite.config.cacheDir)
? mod.id
: false
: false
if (rewrote) {
id = rewrote
externalized.add(id)
seen.set(mod, id)
if (id.startsWith('__vite-browser-external:')) {
const external = id.slice('__vite-browser-external:'.length)
externalized.add(external)
return external
}
else {
inlined.add(id)
const external = project._resolver.wasExternalized(id)
if (typeof external === 'string') {
externalized.add(external)
return external
}
if (browser && mod.file?.includes(project.browser!.vite.config.cacheDir)) {
externalized.add(mod.id)
return id
}
inlined.add(id)
// TODO: cached modules don't have that!
const mods = Array.from(mod.importedModules).filter(
i => i.id && !i.id.includes('/vitest/dist/'),
)
graph[id] = (await Promise.all(mods.map(m => get(m, seen)))).filter(
graph[id] = mods.map(m => get(m)).filter(
Boolean,
) as string[]
return id
}
if (browser && project.browser) {
await get(project.browser.vite.moduleGraph.getModuleById(id))
}
else {
await get(project.vite.moduleGraph.getModuleById(id))
}
get(environment.moduleGraph.getModuleById(testFilePath))
project.config.setupFiles.forEach((setupFile) => {
get(environment.moduleGraph.getModuleById(setupFile))
})
return {
graph,

View File

@ -0,0 +1,26 @@
import type { File } from '@vitest/runner/types'
import type { TestModule } from 'vitest/node'
import { expect, test } from 'vitest'
import { runInlineTests } from '../../test-utils'
// TODO: write comprehensive tests
test.skip('123', async () => {
const source = `
import {} from './hello-world'
import { test } from 'vitest'
// import 'side-effect'
// import * as m from 'module-import'
// import * as m2 from "module-import-2"
test('hello world')
`
const { fs, ctx } = await runInlineTests({
'source.test.js': source,
'hello-world': '',
})
const file = fs.resolveFile('./source.test.js')
const testFile = ctx!.state.filesMap.get(file) as File[] | undefined
const testModule = testFile?.length ? ctx!.state.getReportedEntity(testFile[0]) as TestModule : undefined
const diagnostic = await ctx!.experimental_getSourceModuleDiagnostic(file, testModule)
expect(diagnostic).toBeDefined()
})

View File

@ -86,6 +86,7 @@ exports[`html reporter > resolves to "failing" status for test file "json-fail"
},
],
"type": "suite",
"viteEnvironment": "ssr",
},
],
"moduleGraph": {
@ -191,6 +192,7 @@ exports[`html reporter > resolves to "passing" status for test file "all-passing
},
],
"type": "suite",
"viteEnvironment": "ssr",
},
],
"moduleGraph": {

View File

@ -379,6 +379,9 @@ export function useFS<T extends TestFsStructure>(root: string, structure: T, ens
return fs.statSync(filepath)
},
resolveFile: (file: string): string => {
return resolve(root, file)
},
}
}