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 { html:not(.dark) .custom-block.tip code {
color: var(--vitest-custom-block-tip-code-text) !important; color: var(--vitest-custom-block-tip-code-text) !important;
} }
html:not(.dark) .custom-block.info code { html:not(.dark) .custom-block.info code {
color: var(--vitest-custom-block-info-code-text) !important; color: var(--vitest-custom-block-info-code-text) !important;
} }
.custom-block.tip a:hover, .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; color: var(--vp-c-brand-1) !important;
opacity: 1; opacity: 1;
} }
.custom-block.info a:hover, .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; color: var(--vp-c-brand-1) !important;
opacity: 1; opacity: 1;
} }
html:not(.dark) .custom-block.info a:hover, 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; color: var(--vitest-custom-block-info-code-text) !important;
opacity: 1; opacity: 1;
} }
.custom-block.warning a:hover, .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; color: var(--vp-c-warning-1) !important;
opacity: 1; opacity: 1;
} }
.custom-block.danger a:hover, .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; color: var(--vp-c-danger-1) !important;
opacity: 1; opacity: 1;
} }
@ -70,6 +76,7 @@ html:not(.dark) .vp-doc .custom-block.info a:hover > code {
:not(.dark) .title-icon { :not(.dark) .title-icon {
opacity: 1 !important; opacity: 1 !important;
} }
.dark .title-icon { .dark .title-icon {
opacity: 0.67 !important; opacity: 0.67 !important;
} }
@ -81,6 +88,7 @@ html:not(.dark) .vp-doc .custom-block.info a:hover > code {
.vp-doc a { .vp-doc a {
text-decoration-style: dotted; text-decoration-style: dotted;
} }
.custom-block a:focus, .custom-block a:focus,
.custom-block a:active, .custom-block a:active,
.custom-block a:hover, .custom-block a:hover,
@ -92,7 +100,8 @@ html:not(.dark) .vp-doc .custom-block.info a:hover > code {
text-decoration: underline; text-decoration: underline;
} }
.vp-doc th, .vp-doc td { .vp-doc th,
.vp-doc td {
padding: 6px 10px; padding: 6px 10px;
border: 1px solid #8882; border: 1px solid #8882;
} }
@ -113,14 +122,17 @@ img.resizable-img {
.VPTeamMembersItem.medium .profile .data .affiliation { .VPTeamMembersItem.medium .profile .data .affiliation {
min-height: unset; min-height: unset;
} }
.VPTeamMembersItem.medium .profile .data .desc { .VPTeamMembersItem.medium .profile .data .desc {
min-height: unset; min-height: unset;
} }
/* fix height ~ 2 lines of text: 3 cards per row */ /* fix height ~ 2 lines of text: 3 cards per row */
@media (min-width: 648px) { @media (min-width: 648px) {
.VPTeamMembersItem.medium .profile .data .affiliation { .VPTeamMembersItem.medium .profile .data .affiliation {
min-height: 4rem; min-height: 4rem;
} }
.VPTeamMembersItem.medium .profile .data .desc { .VPTeamMembersItem.medium .profile .data .desc {
min-height: 4rem; min-height: 4rem;
} }
@ -130,6 +142,7 @@ img.resizable-img {
.VPTeamMembersItem.small .profile .data .affiliation { .VPTeamMembersItem.small .profile .data .affiliation {
min-height: 3rem; min-height: 3rem;
} }
.VPTeamMembersItem.small .profile .data .desc { .VPTeamMembersItem.small .profile .data .desc {
min-height: 3rem; min-height: 3rem;
} }
@ -139,33 +152,40 @@ img.resizable-img {
.VPTeamMembersItem.small .profile .data .affiliation { .VPTeamMembersItem.small .profile .data .affiliation {
min-height: 4rem; min-height: 4rem;
} }
.VPTeamMembersItem.small .profile .data .desc { .VPTeamMembersItem.small .profile .data .desc {
min-height: 4rem; min-height: 4rem;
} }
} }
/* fix height ~ 3 lines of text: 3 cards per row */ /* fix height ~ 3 lines of text: 3 cards per row */
@media (min-width: 815px) and (max-width: 875px) { @media (min-width: 815px) and (max-width: 875px) {
.VPTeamMembersItem.small .profile .data .affiliation { .VPTeamMembersItem.small .profile .data .affiliation {
min-height: 4rem; min-height: 4rem;
} }
.VPTeamMembersItem.small .profile .data .desc { .VPTeamMembersItem.small .profile .data .desc {
min-height: 4rem; min-height: 4rem;
} }
} }
/* fix height ~ 3 lines of text: 2 cards per row */ /* fix height ~ 3 lines of text: 2 cards per row */
@media (max-width: 612px) { @media (max-width: 612px) {
.VPTeamMembersItem.small .profile .data .affiliation { .VPTeamMembersItem.small .profile .data .affiliation {
min-height: 4rem; min-height: 4rem;
} }
.VPTeamMembersItem.small .profile .data .desc { .VPTeamMembersItem.small .profile .data .desc {
min-height: 4rem; min-height: 4rem;
} }
} }
/* fix height: one card per row */ /* fix height: one card per row */
@media (max-width: 568px) { @media (max-width: 568px) {
.VPTeamMembersItem.small .profile .data .affiliation { .VPTeamMembersItem.small .profile .data .affiliation {
min-height: unset; min-height: unset;
} }
.VPTeamMembersItem.small .profile .data .desc { .VPTeamMembersItem.small .profile .data .desc {
min-height: unset; min-height: unset;
} }
@ -176,3 +196,30 @@ img.resizable-img {
transition: background-color 0.5s; transition: background-color 0.5s;
display: inline-block; 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. 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 ```ts
interface PluginOptions { interface PluginOptions {

View File

@ -120,3 +120,7 @@ interface ImportDuration {
totalTime: number 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. 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 ```ts
function experimental_parseSpecification( 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. 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 ```ts
function experimental_parseSpecifications( 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. 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 ```ts
function experimental_clearCache(): Promise<void> function experimental_clearCache(): Promise<void>
``` ```
Deletes all Vitest caches, including [`experimental.fsModuleCache`](/config/experimental#experimental-fsmodulecache). 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 ::: 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. 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`. 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`. 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. 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) super(options.config)
this.config = options.config this.config = options.config
this.commands = getBrowserState().commands this.commands = getBrowserState().commands
this.viteEnvironment = '__browser__'
} }
setMethod(method: TestExecutionMethod) { setMethod(method: TestExecutionMethod) {

View File

@ -36,7 +36,7 @@ export async function collectTests(
async () => { async () => {
const testLocations = typeof spec === 'string' ? undefined : spec.testLocations 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)) setFileContext(file, Object.create(null))
file.shuffle = config.sequence.shuffle 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. * The name of the current pool. Can affect how stack trace is inferred on the server side.
*/ */
pool?: string 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'` * 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. */ /** The time spent importing & executing the file and all its imports. */
totalTime: number 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' * @default 'forks'
*/ */
pool?: string pool?: string
/**
* The environment that processes the file on the server.
*/
viteEnvironment?: string
/** /**
* The path to the file in UNIX format. * The path to the file in UNIX format.
*/ */

View File

@ -182,6 +182,7 @@ export function createFileTask(
root: string, root: string,
projectName: string | undefined, projectName: string | undefined,
pool?: string, pool?: string,
viteEnvironment?: string,
): File { ): File {
const path = relative(root, filepath) const path = relative(root, filepath)
const file: File = { const file: File = {
@ -196,6 +197,7 @@ export function createFileTask(
projectName, projectName,
file: undefined!, file: undefined!,
pool, pool,
viteEnvironment,
} }
file.file = file file.file = file
return 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"> <script setup lang="ts">
import type { EditorFromTextArea } from 'codemirror'
import type { Ref } from 'vue' import type { Ref } from 'vue'
import { onMounted, ref, useAttrs } from 'vue' import { onMounted, ref, useAttrs } from 'vue'
import { codemirrorRef, useCodeMirror } from '~/composables/codemirror' import { codemirrorRef, useCodeMirror } from '~/composables/codemirror'
@ -11,6 +12,7 @@ const { mode, readOnly } = defineProps<{
const emit = defineEmits<{ const emit = defineEmits<{
(event: 'save', content: string): void (event: 'save', content: string): void
(event: 'codemirror', codemirror: EditorFromTextArea): void
}>() }>()
const modelValue = defineModel<string>() const modelValue = defineModel<string>()
@ -18,9 +20,9 @@ const modelValue = defineModel<string>()
const attrs = useAttrs() const attrs = useAttrs()
const modeMap: Record<string, any> = { const modeMap: Record<string, any> = {
// html: 'htmlmixed', html: 'htmlmixed',
// vue: 'htmlmixed', vue: 'htmlmixed',
// svelte: 'htmlmixed', svelte: 'htmlmixed',
js: 'javascript', js: 'javascript',
mjs: 'javascript', mjs: 'javascript',
cjs: '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.setSize('100%', '100%')
codemirror.clearHistory() codemirror.clearHistory()
codemirrorRef.value = codemirror codemirrorRef.value = codemirror
setTimeout(() => codemirrorRef.value!.refresh(), 100) setTimeout(() => codemirrorRef.value?.refresh(), 100)
}) })
</script> </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"> <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 { asyncComputed, onKeyStroke } from '@vueuse/core'
import { Tooltip as VueTooltip } from 'floating-vue'
import { join, relative } from 'pathe'
import { computed } from 'vue' 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 CodeMirrorContainer from './CodeMirrorContainer.vue'
import IconButton from './IconButton.vue' import IconButton from './IconButton.vue'
const props = defineProps<{ id: string; projectName: string }>() const props = defineProps<{
const emit = defineEmits<{ (e: 'close'): void }>() id: string
projectName: string
type: ModuleType
canUndo: boolean
}>()
const result = asyncComputed(() => const emit = defineEmits<{
client.rpc.getTransformResult(props.projectName, props.id, !!browserState), (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 ext = computed(() => props.id?.split(/\./g).pop() || 'js')
const source = computed(() => result.value?.source?.trim() || '') 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( 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(() => ({ const sourceMap = computed(() => {
mappings: result.value?.map?.mappings ?? '', if (!result.value || !('map' in result.value)) {
version: (result.value?.map as any)?.version, 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', () => { onKeyStroke('Escape', () => {
emit('close') emit('close')
@ -32,7 +203,75 @@ onKeyStroke('Escape', () => {
<template> <template>
<div w-350 max-w-screen h-full flex flex-col> <div w-350 max-w-screen h-full flex flex-col>
<div p-4 relative> <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> <p op50 font-mono text-sm>
{{ id }} {{ id }}
</p> </p>
@ -49,26 +288,29 @@ onKeyStroke('Escape', () => {
No transform result found for this module. No transform result found for this module.
</div> </div>
<template v-else> <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"> <div p="x3 y-1" bg-overlay border="base b t r">
Source Source
</div> </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 Transformed
</div> </div>
<CodeMirrorContainer <CodeMirrorContainer
:key="id"
h-full h-full
:model-value="source" :model-value="source"
read-only read-only
v-bind="{ lineNumbers: true }" v-bind="{ lineNumbers: true }"
:mode="ext" :mode="ext"
@codemirror="markImportDurations($event)"
/> />
<CodeMirrorContainer <CodeMirrorContainer
v-if="code != null"
h-full h-full
:model-value="code" :model-value="code"
read-only read-only
v-bind="{ lineNumbers: true }" v-bind="{ lineNumbers: true }"
:mode="ext" mode="js"
/> />
</div> </div>
<div v-if="sourceMap.mappings !== ''"> <div v-if="sourceMap.mappings !== ''">
@ -79,7 +321,6 @@ onKeyStroke('Escape', () => {
:model-value="sourceMap.mappings" :model-value="sourceMap.mappings"
read-only read-only
v-bind="{ lineNumbers: true }" v-bind="{ lineNumbers: true }"
:mode="ext"
/> />
</div> </div>
</template> </template>

View File

@ -8,16 +8,20 @@ import type {
ModuleNode, ModuleNode,
ModuleType, ModuleType,
} from '~/composables/module-graph' } from '~/composables/module-graph'
import { useRefHistory } from '@vueuse/core'
import { import {
defineGraphConfig, defineGraphConfig,
defineNode,
GraphController, GraphController,
Markers, Markers,
PositionInitializers, PositionInitializers,
} from 'd3-graph-controller' } from 'd3-graph-controller'
import { onMounted, onUnmounted, ref, toRefs, watch, watchEffect } from 'vue' import { computed, onMounted, onUnmounted, ref, shallowRef, toRefs, watch } from 'vue'
import { isReport } from '~/composables/client' import { config, isReport } from '~/composables/client'
import { currentModule } from '~/composables/navigation'
import IconButton from '../IconButton.vue' import IconButton from '../IconButton.vue'
import Modal from '../Modal.vue' import Modal from '../Modal.vue'
import ViewModuleGraphImportBreakdown from '../ModuleGraphImportBreakdown.vue'
import ModuleTransformResultView from '../ModuleTransformResultView.vue' import ModuleTransformResultView from '../ModuleTransformResultView.vue'
const props = defineProps<{ const props = defineProps<{
@ -32,19 +36,30 @@ const { graph } = toRefs(props)
const el = ref<HTMLDivElement>() const el = ref<HTMLDivElement>()
const modalShow = ref(false) 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>() const controller = ref<ModuleGraphController | undefined>()
const focusedNode = ref<string | null>(null)
watchEffect( const filteredGraph = shallowRef<ModuleGraph>(graph.value)
() => { const breakdownIconClass = computed(() => {
if (modalShow.value === false) { let textClass = ''
setTimeout(() => (selectedModule.value = undefined), 300) const importDurations = currentModule.value?.importDurations || {}
for (const moduleId in importDurations) {
const { totalTime } = importDurations[moduleId]
if (totalTime >= 500) {
textClass = 'text-red'
break
} }
}, else if (totalTime >= 100) {
{ flush: 'post' }, textClass = 'text-orange'
) }
}
return textClass
})
const breakdownShow = ref(config.value?.experimental?.printImportBreakdown ?? breakdownIconClass.value === 'text-red')
onMounted(() => { onMounted(() => {
filteredGraph.value = filterGraphByLevels(graph.value, null, 2)
resetGraphController() resetGraphController()
}) })
@ -52,17 +67,198 @@ onUnmounted(() => {
controller.value?.shutdown() 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) { function setFilter(name: ModuleType, value: boolean) {
controller.value?.filterNodesByType(value, name) controller.value?.filterNodesByType(value, name)
} }
function setSelectedModule(id: string) { function setSelectedModule(id: string, type: ModuleType) {
selectedModule.value = id selectedModule.value = { id, type }
modalShow.value = true 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) { function resetGraphController(reset = false) {
controller.value?.shutdown() controller.value?.shutdown()
@ -73,13 +269,33 @@ function resetGraphController(reset = false) {
return return
} }
if (!graph.value || !el.value) { if (!filteredGraph.value || !el.value) {
return 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( controller.value = new GraphController(
el.value!, el.value!,
graph.value, filteredGraph.value,
// See https://graph-controller.yeger.eu/config/ for more options // See https://graph-controller.yeger.eu/config/ for more options
defineGraphConfig<ModuleType, ModuleNode, ModuleLink>({ defineGraphConfig<ModuleType, ModuleNode, ModuleLink>({
nodeRadius: 10, nodeRadius: 10,
@ -92,7 +308,7 @@ function resetGraphController(reset = false) {
if (willBeHidden) { if (willBeHidden) {
return 0 return 0
} }
return 0.25 return 0.05
}, },
}, },
forces: { forces: {
@ -100,7 +316,7 @@ function resetGraphController(reset = false) {
radiusMultiplier: 10, radiusMultiplier: 10,
}, },
link: { link: {
length: 240, length: 140,
}, },
}, },
}, },
@ -108,19 +324,20 @@ function resetGraphController(reset = false) {
modifiers: { modifiers: {
node: bindOnClick, node: bindOnClick,
}, },
positionInitializer: positionInitializer: graph.value.nodes.length === 1
graph.value.nodes.length > 1 ? PositionInitializers.Centered
? PositionInitializers.Randomized : PositionInitializers.Randomized,
: PositionInitializers.Centered,
zoom: { zoom: {
min: 0.5, initial: zoom,
max: 2, min,
max: 1.5,
}, },
}), }),
) )
} }
const isValidClick = (event: PointerEvent) => event.button === 0 const isValidClick = (event: PointerEvent) => event.button === 0
const isRightClick = (event: PointerEvent) => event.button === 2
function bindOnClick( function bindOnClick(
selection: Selection<SVGCircleElement, ModuleNode, SVGGElement, undefined>, selection: Selection<SVGCircleElement, ModuleNode, SVGGElement, undefined>,
@ -128,51 +345,78 @@ function bindOnClick(
if (isReport) { if (isReport) {
return return
} }
// Only trigger on left-click and primary touch // Handle both left-click (focus) and right-click (open modal)
let px = 0 let px = 0
let py = 0 let py = 0
let pt = 0 let pt = 0
let isRightClickDown = false
selection selection
.on('pointerdown', (event: PointerEvent, node) => { .on('pointerdown', (event: PointerEvent, node) => {
if (node.type === 'external') { if (!node.x || !node.y) {
return return
} }
if (!node.x || !node.y || !isValidClick(event)) {
isRightClickDown = isRightClick(event)
if (!isValidClick(event) && !isRightClickDown) {
return return
} }
px = node.x px = node.x
py = node.y py = node.y
pt = Date.now() pt = Date.now()
}) })
.on('pointerup', (event: PointerEvent, node: ModuleNode) => { .on('pointerup', (event: PointerEvent, node: ModuleNode) => {
if (node.type === 'external') { if (!node.x || !node.y) {
return return
} }
if (!node.x || !node.y || !isValidClick(event)) {
const wasRightClick = isRightClick(event)
if (!isValidClick(event) && !wasRightClick) {
return return
} }
if (Date.now() - pt > 500) { if (Date.now() - pt > 500) {
return return
} }
const dx = node.x - px const dx = node.x - px
const dy = node.y - py const dy = node.y - py
if (dx ** 2 + dy ** 2 < 100) { 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> </script>
<template> <template>
<div h-full min-h-75 flex-1 overflow="hidden"> <div h-full min-h-75 flex-1 overflow="hidden">
<div> <div>
<div flex items-center gap-4 px-3 py-2> <div flex items-center gap-2 px-3 py-2>
<div <div
flex="~ gap-1" flex="~ gap-1"
items-center items-center
select-none select-none
> >
<div class="pr-2">
{{ filteredGraph.nodes.length }}/{{ graph.nodes.length }} {{ filteredGraph.nodes.length === 1 ? 'module' : 'modules' }}
</div>
<input <input
id="hide-node-modules" id="hide-node-modules"
v-model="hideNodeModules" v-model="hideNodeModules"
@ -217,23 +461,53 @@ function bindOnClick(
>{{ node }} Modules</label> >{{ node }} Modules</label>
</div> </div>
<div flex-auto /> <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> <div>
<IconButton <IconButton
v-tooltip.bottom="'Reset'" v-tooltip.bottom="'Reset'"
icon="i-carbon-reset" icon="i-carbon-reset"
@click="resetGraphController(true)" @click="resetToRoot()"
/> />
</div> </div>
</div> </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" /> <div ref="el" />
<Modal v-model="modalShow" direction="right"> <Modal v-model="modalShow" direction="right">
<template v-if="selectedModule"> <template v-if="selectedModule">
<Suspense> <Suspense>
<ModuleTransformResultView <ModuleTransformResultView
:id="selectedModule" :id="selectedModule.id"
:project-name="projectName" :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> </Suspense>
</template> </template>
@ -245,9 +519,10 @@ function bindOnClick(
:root { :root {
--color-link-label: var(--color-text); --color-link-label: var(--color-text);
--color-link: #ddd; --color-link: #ddd;
--color-node-external: #c0ad79; --color-node-external: #6C5C33;
--color-node-inline: #8bc4a0; --color-node-inline: #8bc4a0;
--color-node-root: #6e9aa5; --color-node-root: #6e9aa5;
--color-node-focused: #e67e22;
--color-node-label: var(--color-text); --color-node-label: var(--color-text);
--color-node-stroke: var(--color-text); --color-node-stroke: var(--color-text);
} }
@ -255,9 +530,10 @@ function bindOnClick(
html.dark { html.dark {
--color-text: #fff; --color-text: #fff;
--color-link: #333; --color-link: #333;
--color-node-external: #857a40; --color-node-external: #c0ad79;
--color-node-inline: #468b60; --color-node-inline: #468b60;
--color-node-root: #467d8b; --color-node-root: #467d8b;
--color-node-focused: #f39c12;
} }
.graph { .graph {

View File

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

View File

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

View File

@ -7,6 +7,7 @@ import type {
} from 'd3-graph-controller' } from 'd3-graph-controller'
import type { ModuleGraphData } from 'vitest' import type { ModuleGraphData } from 'vitest'
import { defineGraph, defineLink, defineNode } from 'd3-graph-controller' import { defineGraph, defineLink, defineNode } from 'd3-graph-controller'
import { calcExternalLabels, createModuleLabelItem } from '~/utils/task'
export type ModuleType = 'external' | 'inline' export type ModuleType = 'external' | 'inline'
export type ModuleNode = GraphNode<ModuleType> export type ModuleNode = GraphNode<ModuleType>
@ -19,95 +20,20 @@ export type ModuleGraphController = GraphController<
> >
export type ModuleGraphConfig = GraphConfig<ModuleType, ModuleNode, ModuleLink> 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[] { function defineExternalModuleNodes(modules: string[]): ModuleNode[] {
const labels: ModuleLabelItem[] = modules.map(module => const labels = modules.map(module =>
createModuleLabelItem(module), createModuleLabelItem(module),
) )
const map = calcExternalLabels(labels) const map = calcExternalLabels(labels)
return labels.map(({ raw, id }) => { return labels.map(({ raw, id, splits }) => {
return defineNode<ModuleType, ModuleNode>({ return defineNode<ModuleType, ModuleNode>({
color: 'var(--color-node-external)', color: 'var(--color-node-external)',
label: { label: {
color: 'var(--color-node-external)', color: 'var(--color-node-external)',
fontSize: '0.875rem', fontSize: '0.875rem',
text: map.get(raw) ?? '', text: id.includes('node_modules')
? (map.get(raw) ?? raw)
: splits.pop()!,
}, },
isFocused: false, isFocused: false,
id, id,

View File

@ -18,6 +18,121 @@ export function caseInsensitiveMatch(target: string, str2: string) {
return target.toLowerCase().includes(str2.toLowerCase()) 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) { export function getProjectNameColor(name: string | undefined) {
if (!name) { if (!name) {
return '' return ''

View File

@ -9,6 +9,7 @@ import type { TestSpecification } from '../node/spec'
import type { Reporter } from '../node/types/reporter' import type { Reporter } from '../node/types/reporter'
import type { LabelColor, ModuleGraphData, UserConsoleLog } from '../types/general' import type { LabelColor, ModuleGraphData, UserConsoleLog } from '../types/general'
import type { import type {
ExternalResult,
TransformResultWithSource, TransformResultWithSource,
WebSocketEvents, WebSocketEvents,
WebSocketHandlers, WebSocketHandlers,
@ -19,8 +20,10 @@ import { performance } from 'node:perf_hooks'
import { noop } from '@vitest/utils/helpers' import { noop } from '@vitest/utils/helpers'
import { createBirpc } from 'birpc' import { createBirpc } from 'birpc'
import { parse, stringify } from 'flatted' import { parse, stringify } from 'flatted'
import { isFileServingAllowed } from 'vite'
import { WebSocketServer } from 'ws' import { WebSocketServer } from 'ws'
import { API_PATH } from '../constants' import { API_PATH } from '../constants'
import { getTestFileEnvironment } from '../utils/environments'
import { getModuleGraph } from '../utils/graph' import { getModuleGraph } from '../utils/graph'
import { stringifyReplace } from '../utils/serialization' import { stringifyReplace } from '../utils/serialization'
import { isValidApiRequest } from './check' import { isValidApiRequest } from './check'
@ -91,18 +94,57 @@ export function setup(ctx: Vitest, _server?: ViteDevServer): void {
getResolvedProjectLabels(): { name: string; color?: LabelColor }[] { getResolvedProjectLabels(): { name: string; color?: LabelColor }[] {
return ctx.projects.map(p => ({ name: p.name, color: p.color })) return ctx.projects.map(p => ({ name: p.name, color: p.color }))
}, },
async getTransformResult(projectName: string, id, browser = false) { async getExternalResult(moduleId: string, testFileTaskId: string) {
const project = ctx.getProjectByName(projectName) const testModule = ctx.state.getReportedEntityById(testFileTaskId) as TestModule | undefined
const result: TransformResultWithSource | null | undefined = browser if (!testModule) {
? await project.browser!.vite.transformRequest(id) return undefined
: await project.vite.transformRequest(id)
if (result) {
try {
result.source = result.source || (await fs.readFile(id, 'utf-8'))
}
catch {}
return result
} }
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> { async getModuleGraph(project, id, browser): Promise<ModuleGraphData> {
return getModuleGraph(ctx, project, id, browser) 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 { SerializedConfig } from '../runtime/config'
import type { SerializedTestSpecification } from '../runtime/types/utils' import type { SerializedTestSpecification } from '../runtime/types/utils'
import type { LabelColor, ModuleGraphData, UserConsoleLog } from '../types/general' import type { LabelColor, ModuleGraphData, UserConsoleLog } from '../types/general'
import type { ModuleDefinitionDurationsDiagnostic, UntrackedModuleDefinitionDiagnostic } from '../types/module-locations'
interface SourceMap { interface SourceMap {
file: string file: string
@ -16,6 +17,10 @@ interface SourceMap {
toUrl: () => string toUrl: () => string
} }
export interface ExternalResult {
source?: string
}
export interface TransformResultWithSource { export interface TransformResultWithSource {
code: string code: string
map: SourceMap | { map: SourceMap | {
@ -25,6 +30,9 @@ export interface TransformResultWithSource {
deps?: string[] deps?: string[]
dynamicDeps?: string[] dynamicDeps?: string[]
source?: string source?: string
transformTime?: number
modules?: ModuleDefinitionDurationsDiagnostic[]
untrackedModules?: UntrackedModuleDefinitionDiagnostic[]
} }
export interface WebSocketHandlers { export interface WebSocketHandlers {
@ -42,8 +50,13 @@ export interface WebSocketHandlers {
getTransformResult: ( getTransformResult: (
projectName: string, projectName: string,
id: string, id: string,
testFileId: string,
browser?: boolean, browser?: boolean,
) => Promise<TransformResultWithSource | undefined> ) => Promise<TransformResultWithSource | undefined>
getExternalResult: (
id: string,
testFileId: string,
) => Promise<ExternalResult | undefined>
readTestFile: (id: string) => Promise<string | null> readTestFile: (id: string) => Promise<string | null>
saveTestFile: (id: string, content: string) => Promise<void> saveTestFile: (id: string, content: string) => Promise<void>
rerun: (files: string[], resetTestNamePattern?: boolean) => Promise<void> rerun: (files: string[], resetTestNamePattern?: boolean) => Promise<void>

View File

@ -12,6 +12,7 @@ import {
import { ancestor as walkAst } from 'acorn-walk' import { ancestor as walkAst } from 'acorn-walk'
import { relative } from 'pathe' import { relative } from 'pathe'
import { parseAst } from 'vite' import { parseAst } from 'vite'
import { createIndexLocationsMap } from '../utils/base'
import { createDebugger } from '../utils/debugger' import { createDebugger } from '../utils/debugger'
interface ParsedFile extends File { interface ParsedFile extends File {
@ -265,7 +266,7 @@ function createFileTask(
file: null!, file: null!,
} }
file.file = file file.file = file
const indexMap = createIndexMap(code) const indexMap = createIndexLocationsMap(code)
const map = requestMap && new TraceMap(requestMap) const map = requestMap && new TraceMap(requestMap)
let lastSuite: ParsedSuite = file as any let lastSuite: ParsedSuite = file as any
const updateLatestSuite = (index: number) => { 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) 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[]) { function markDynamicTests(tasks: Task[]) {
for (const task of tasks) { for (const task of tasks) {
if (task.dynamic) { if (task.dynamic) {

View File

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

View File

@ -775,6 +775,9 @@ export const cliOptionsConfig: VitestCLIOptions = {
}, },
fsModuleCachePath: null, fsModuleCachePath: null,
openTelemetry: 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 // disable CLI options

View File

@ -132,6 +132,7 @@ export function serializeConfig(project: TestProject): SerializedConfig {
: project._serializedDefines || '', : project._serializedDefines || '',
experimental: { experimental: {
fsModuleCache: config.experimental.fsModuleCache ?? false, 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 { ModuleRunner } from 'vite/module-runner'
import type { SerializedCoverageConfig } from '../runtime/config' import type { SerializedCoverageConfig } from '../runtime/config'
import type { ArgumentsType, ProvidedContext, UserConsoleLog } from '../types/general' 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 { CliOptions } from './cli/cli-api'
import type { VitestFetchFunction } from './environments/fetchModule' import type { VitestFetchFunction } from './environments/fetchModule'
import type { ProcessPool } from './pool' import type { ProcessPool } from './pool'
@ -35,6 +36,7 @@ import { createFetchModuleFunction } from './environments/fetchModule'
import { ServerModuleRunner } from './environments/serverRunner' import { ServerModuleRunner } from './environments/serverRunner'
import { FilesNotFoundError } from './errors' import { FilesNotFoundError } from './errors'
import { Logger } from './logger' import { Logger } from './logger'
import { collectModuleDurationsDiagnostic, collectSourceModulesLocations } from './module-diagnostic'
import { VitestPackageInstaller } from './packageInstaller' import { VitestPackageInstaller } from './packageInstaller'
import { createPool } from './pool' import { createPool } from './pool'
import { TestProject } from './project' 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?: { public async experimental_parseSpecifications(specifications: TestSpecification[], options?: {
/** @default os.availableParallelism() */ /** @default os.availableParallelism() */
concurrency?: number concurrency?: number

View File

@ -24,7 +24,6 @@ class ModuleFetcher {
private resolver: VitestResolver, private resolver: VitestResolver,
private config: ResolvedConfig, private config: ResolvedConfig,
private fsCache: FileSystemModuleCache, private fsCache: FileSystemModuleCache,
private traces: Traces,
private tmpProjectDir: string, private tmpProjectDir: string,
) { ) {
this.fsCacheEnabled = config.experimental?.fsModuleCache === true this.fsCacheEnabled = config.experimental?.fsModuleCache === true
@ -125,12 +124,23 @@ class ModuleFetcher {
const result = await this.fetchAndProcess(environment, url, importer, moduleGraphModule, options) const result = await this.fetchAndProcess(environment, url, importer, moduleGraphModule, options)
const importers = this.getSerializedDependencies(moduleGraphModule) const importers = this.getSerializedDependencies(moduleGraphModule)
const importedUrls = this.getSerializedImports(moduleGraphModule)
const map = moduleGraphModule.transformResult?.map const map = moduleGraphModule.transformResult?.map
const mappings = map && !('version' in map) && map.mappings === '' 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[] { private getSerializedDependencies(node: EnvironmentModuleNode): string[] {
const dependencies: string[] = [] const dependencies: string[] = []
node.importers.forEach((importer) => { 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 { return {
cached: true as const, cached: true as const,
file: cachedModule.file, file: cachedModule.file,
@ -293,6 +310,7 @@ class ModuleFetcher {
result: FetchResult, result: FetchResult,
cachePath: string, cachePath: string,
importers: string[] = [], importers: string[] = [],
importedUrls: string[] = [],
mappings = false, mappings = false,
): Promise<FetchResult | FetchCachedFileSystemResult> { ): Promise<FetchResult | FetchCachedFileSystemResult> {
const returnResult = 'code' in result const returnResult = 'code' in result
@ -305,7 +323,7 @@ class ModuleFetcher {
} }
const savePromise = this.fsCache const savePromise = this.fsCache
.saveCachedModule(cachePath, result, importers, mappings) .saveCachedModule(cachePath, result, importers, importedUrls, mappings)
.then(() => result) .then(() => result)
.finally(() => { .finally(() => {
saveCachePromises.delete(cachePath) saveCachePromises.delete(cachePath)
@ -349,7 +367,7 @@ export function createFetchModuleFunction(
traces: Traces, traces: Traces,
tmpProjectDir: string, tmpProjectDir: string,
): VitestFetchFunction { ): 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) => { return async (url, importer, environment, cacheFs, options, otelCarrier) => {
await traces.waitInit() await traces.waitInit()
const context = otelCarrier 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 { parseStacktrace } from '@vitest/utils/source-map'
import { relative } from 'pathe' import { relative } from 'pathe'
import c from 'tinyrainbow' import c from 'tinyrainbow'
import { groupBy } from '../../utils/base'
import { isTTY } from '../../utils/env' import { isTTY } from '../../utils/env'
import { hasFailedSnapshot } from '../../utils/tasks' import { hasFailedSnapshot } from '../../utils/tasks'
import { F_CHECK, F_DOWN_RIGHT, F_POINTER } from './renderers/figures' 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() 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[]) { private printErrorsSummary(files: File[], errors: unknown[]) {
const suites = getSuites(files) const suites = getSuites(files)
const tests = getTests(files) const tests = getTests(files)

View File

@ -9,6 +9,7 @@ import type {
TestArtifact, TestArtifact,
} from '@vitest/runner' } from '@vitest/runner'
import type { SerializedError, TestError } from '@vitest/utils' import type { SerializedError, TestError } from '@vitest/utils'
import type { DevEnvironment } from 'vite'
import type { TestProject } from '../project' import type { TestProject } from '../project'
class ReportedTaskImplementation { class ReportedTaskImplementation {
@ -440,6 +441,13 @@ export class TestModule extends SuiteImplementation {
declare public readonly location: undefined declare public readonly location: undefined
public readonly type = 'module' 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. * This is usually an absolute UNIX file path.
* It can be a virtual ID if the file is not on the disk. * 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) super(task, project)
this.moduleId = task.filepath this.moduleId = task.filepath
this.relativeModuleId = task.name 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 { export class VitestResolver {
public readonly options: ExternalizeOptions 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) { constructor(cacheDir: string, config: ResolvedConfig) {
// sorting to make cache consistent // sorting to make cache consistent
@ -38,8 +39,26 @@ export class VitestResolver {
} }
} }
public shouldExternalize(file: string): Promise<string | false | undefined> { public wasExternalized(file: string): string | false {
return shouldExternalize(normalizeId(file), this.options, this.externalizeCache) 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 enabled: boolean
sdkPath?: string 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 { SerializedTestSpecification } from '../runtime/types/utils'
import type {
ModuleDefinitionDiagnostic,
ModuleDefinitionDurationsDiagnostic,
ModuleDefinitionLocation,
SourceModuleDiagnostic,
SourceModuleLocations,
UntrackedModuleDefinitionDiagnostic,
} from '../types/module-locations'
import '../types/global' import '../types/global'
// eslint-disable-next-line ts/no-namespace
export declare namespace Experimental {
export {
ModuleDefinitionDiagnostic,
ModuleDefinitionDurationsDiagnostic,
ModuleDefinitionLocation,
SourceModuleDiagnostic,
SourceModuleLocations,
UntrackedModuleDefinitionDiagnostic,
}
}
export type { export type {
ExternalResult,
TransformResultWithSource, TransformResultWithSource,
WebSocketEvents, WebSocketEvents,
WebSocketHandlers, WebSocketHandlers,
@ -39,6 +59,7 @@ export { expectTypeOf } from '../typecheck/expectTypeOf'
export type { ExpectTypeOf } from '../typecheck/expectTypeOf' export type { ExpectTypeOf } from '../typecheck/expectTypeOf'
export type { BrowserTesterOptions } from '../types/browser' export type { BrowserTesterOptions } from '../types/browser'
// export type * as Experimental from '../types/experimental'
export type { export type {
AfterSuiteRunMeta, AfterSuiteRunMeta,
LabelColor, LabelColor,

View File

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

View File

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

View File

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

View File

@ -38,8 +38,12 @@ export class VitestTestRunner implements VitestRunner {
public pool: string = this.workerState.ctx.pool public pool: string = this.workerState.ctx.pool
private _otel!: Traces 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 { importFile(filepath: string, source: VitestRunnerImportSource): unknown {
if (source === 'setup') { if (source === 'setup') {
@ -226,10 +230,12 @@ export class VitestTestRunner implements VitestRunner {
const importDurations: Record<string, ImportDuration> = {} const importDurations: Record<string, ImportDuration> = {}
const entries = this.workerState.moduleExecutionInfo?.entries() || [] 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)] = { importDurations[normalize(filepath)] = {
selfTime, selfTime,
totalTime: duration, 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 { basename, join, resolve } from 'pathe'
import { x } from 'tinyexec' import { x } from 'tinyexec'
import { distDir } from '../paths' import { distDir } from '../paths'
import { createLocationsIndexMap } from '../utils/base'
import { convertTasksToEvents } from '../utils/tasks' import { convertTasksToEvents } from '../utils/tasks'
import { collectTests } from './collect' import { collectTests } from './collect'
import { getRawErrsMapFromTsCompile } from './parse' import { getRawErrsMapFromTsCompile } from './parse'
import { createIndexMap } from './utils'
export class TypeCheckError extends Error { export class TypeCheckError extends Error {
name = 'TypeCheckError' name = 'TypeCheckError'
@ -146,7 +146,7 @@ export class Typechecker {
] ]
// has no map for ".js" files that use // @ts-check // has no map for ".js" files that use // @ts-check
const traceMap = (map && new TraceMap(map as any)) const traceMap = (map && new TraceMap(map as any))
const indexMap = createIndexMap(parsed) const indexMap = createLocationsIndexMap(parsed)
const markState = (task: Task, state: TaskState) => { const markState = (task: Task, state: TaskState) => {
task.result = { task.result = {
state: 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 { getCallLastIndex, nanoid, notNullish } from '@vitest/utils/helpers'
export function groupBy<T, K extends string | number | symbol>( export function groupBy<T, K extends string | number | symbol>(
@ -38,3 +40,39 @@ export function wildcardPatternToRegExp(pattern: string): RegExp {
return new RegExp(`^${regexp}`, 'i') 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 { Vitest } from '../node/core'
import type { ModuleGraphData } from '../types/general' import type { ModuleGraphData } from '../types/general'
import { getTestFileEnvironment } from './environments'
export async function getModuleGraph( export async function getModuleGraph(
ctx: Vitest, ctx: Vitest,
projectName: string, projectName: string,
id: string, testFilePath: string,
browser = false, browser = false,
): Promise<ModuleGraphData> { ): Promise<ModuleGraphData> {
const graph: Record<string, string[]> = {} const graph: Record<string, string[]> = {}
@ -14,46 +15,59 @@ export async function getModuleGraph(
const project = ctx.getProjectByName(projectName) 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) { if (!mod || !mod.id) {
return 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 return
} }
if (seen.has(mod)) { if (seen.has(mod)) {
return seen.get(mod) return seen.get(mod)
} }
let id = clearId(mod.id) const id = clearId(mod.id)
seen.set(mod, id) seen.set(mod, id)
// TODO: how to know if it was rewritten(?) - what is rewritten? if (id.startsWith('__vite-browser-external:')) {
const rewrote = browser const external = id.slice('__vite-browser-external:'.length)
? mod.file?.includes(project.browser!.vite.config.cacheDir) externalized.add(external)
? mod.id return external
: false
: false
if (rewrote) {
id = rewrote
externalized.add(id)
seen.set(mod, id)
} }
else { const external = project._resolver.wasExternalized(id)
inlined.add(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( const mods = Array.from(mod.importedModules).filter(
i => i.id && !i.id.includes('/vitest/dist/'), 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, Boolean,
) as string[] ) as string[]
return id return id
} }
if (browser && project.browser) {
await get(project.browser.vite.moduleGraph.getModuleById(id)) get(environment.moduleGraph.getModuleById(testFilePath))
} project.config.setupFiles.forEach((setupFile) => {
else { get(environment.moduleGraph.getModuleById(setupFile))
await get(project.vite.moduleGraph.getModuleById(id)) })
}
return { return {
graph, 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", "type": "suite",
"viteEnvironment": "ssr",
}, },
], ],
"moduleGraph": { "moduleGraph": {
@ -191,6 +192,7 @@ exports[`html reporter > resolves to "passing" status for test file "all-passing
}, },
], ],
"type": "suite", "type": "suite",
"viteEnvironment": "ssr",
}, },
], ],
"moduleGraph": { "moduleGraph": {

View File

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