From ff1600403db8687ec9ade3bf68a667d899b6d83c Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Wed, 25 Feb 2026 16:04:57 +0900 Subject: [PATCH 01/15] fix: save and restore module graph in blob reporter --- packages/vitest/src/node/reporters/blob.ts | 158 ++++++++++++++++----- 1 file changed, 123 insertions(+), 35 deletions(-) diff --git a/packages/vitest/src/node/reporters/blob.ts b/packages/vitest/src/node/reporters/blob.ts index e2e89ce47985..9beed554b298 100644 --- a/packages/vitest/src/node/reporters/blob.ts +++ b/packages/vitest/src/node/reporters/blob.ts @@ -1,5 +1,6 @@ import type { File } from '@vitest/runner' import type { SerializedError } from '@vitest/utils' +import type { EnvironmentModuleNode } from 'vite' import type { Vitest } from '../core' import type { TestProject } from '../project' import type { Reporter } from '../types/reporter' @@ -54,27 +55,59 @@ export class BlobReporter implements Reporter { : '.vitest-reports/blob.json' } - const modules = this.ctx.projects.map( - (project) => { - return [ - project.name, - [...project.vite.moduleGraph.idToModuleMap.entries()].map((mod) => { - if (!mod[1].file) { - return null + const environmentModules: MergeReportEnvironmentModules = {} + this.ctx.projects.forEach((project) => { + environmentModules[project.name] = {} + Object.entries(project.vite.environments).forEach(([environmentName, environment]) => { + const idTable: string[] = [] + const idMap = new Map() + + const getIdIndex = (id: string) => { + const existing = idMap.get(id) + if (existing != null) { + return existing + } + const next = idTable.length + idMap.set(id, next) + idTable.push(id) + return next + } + + const modules: SerializedEnvironmentModuleNode[] = [] + for (const [id, mod] of environment.moduleGraph.idToModuleMap.entries()) { + if (!mod.file) { + continue + } + + const importedIds: number[] = [] + for (const importedNode of mod.importedModules) { + if (importedNode.id) { + importedIds.push(getIdIndex(importedNode.id)) } - return [mod[0], mod[1].file, mod[1].url] - }).filter(x => x != null), - ] - }, - ) + } + + modules.push([ + getIdIndex(id), + getIdIndex(mod.file), + getIdIndex(mod.url), + importedIds, + ]) + } + + environmentModules[project.name][environmentName] = { + idTable, + modules, + } + }) + }) const report = [ this.ctx.version, files, errors, - modules, coverage, executionTime, + environmentModules, ] satisfies MergeReport const reportFile = resolve(this.ctx.config.root, outputFile) @@ -112,7 +145,7 @@ export async function readBlobs( ) } const content = await readFile(fullPath, 'utf-8') - const [version, files, errors, moduleKeys, coverage, executionTime] = parse( + const [version, files, errors, coverage, executionTime, environmentModules] = parse( content, ) as MergeReport if (!version) { @@ -120,7 +153,7 @@ export async function readBlobs( `vitest.mergeReports() expects all paths in "${blobsDirectory}" to be files generated by the blob reporter, but "${filename}" is not a valid blob file`, ) } - return { version, files, errors, moduleKeys, coverage, file: filename, executionTime } + return { version, files, errors, coverage, file: filename, executionTime, environmentModules } }) const blobs = await Promise.all(promises) @@ -143,27 +176,77 @@ export async function readBlobs( ) } - // fake module graph - it is used to check if module is imported, but we don't use values inside + // Restore per-environment module graphs so merge mode can reuse + // the same module graph based flows as regular test runs. const projects = Object.fromEntries( projectsArray.map(p => [p.name, p]), ) blobs.forEach((blob) => { - blob.moduleKeys.forEach(([projectName, moduleIds]) => { + Object.entries(blob.environmentModules).forEach(([projectName, environments]) => { const project = projects[projectName] if (!project) { return } - moduleIds.forEach(([moduleId, file, url]) => { - const moduleNode = project.vite.moduleGraph.createFileOnlyEntry(file) - moduleNode.url = url - moduleNode.id = moduleId - moduleNode.transformResult = { - // print error checks that transformResult is set - code: ' ', - map: null, + + const createdByEnvironment = new Map>() + + Object.entries(environments).forEach(([environmentName, moduleGraph]) => { + const environment = project.vite.environments[environmentName] + if (!environment) { + return } - project.vite.moduleGraph.idToModuleMap.set(moduleId, moduleNode) + + const nodesById = new Map() + createdByEnvironment.set(environmentName, nodesById) + + moduleGraph.modules.forEach(([id, file, url]) => { + const moduleId = moduleGraph.idTable[id] + const filePath = moduleGraph.idTable[file] + const urlPath = moduleGraph.idTable[url] + if (!moduleId || !filePath || !urlPath) { + return + } + const moduleNode = environment.moduleGraph.createFileOnlyEntry(filePath) + moduleNode.url = urlPath + moduleNode.id = moduleId + moduleNode.transformResult = { + code: ' ', + map: null, + } + environment.moduleGraph.idToModuleMap.set(moduleId, moduleNode) + nodesById.set(moduleId, moduleNode) + }) + }) + + Object.entries(environments).forEach(([environmentName, moduleGraph]) => { + const nodesById = createdByEnvironment.get(environmentName) + if (!nodesById) { + return + } + + moduleGraph.modules.forEach(([id, _file, _url, importedIds]) => { + const moduleId = moduleGraph.idTable[id] + if (!moduleId) { + return + } + const moduleNode = nodesById.get(moduleId) + if (!moduleNode) { + return + } + importedIds.forEach((importedIdIndex) => { + const importedId = moduleGraph.idTable[importedIdIndex] + if (!importedId) { + return + } + const importedNode = nodesById.get(importedId) + if (!importedNode) { + return + } + moduleNode.importedModules.add(importedNode) + importedNode.importers.add(moduleNode) + }) + }) }) }) }) @@ -198,18 +281,23 @@ type MergeReport = [ vitestVersion: string, files: File[], errors: unknown[], - modules: MergeReportModuleKeys[], coverage: unknown, executionTime: number, + environmentModules: MergeReportEnvironmentModules, ] -type SerializedModuleNode = [ - id: string, - file: string, - url: string, -] +type MergeReportEnvironmentModules = { + [projectName: string]: { + [environmentName: string]: { + idTable: string[] + modules: SerializedEnvironmentModuleNode[] + } + } +} -type MergeReportModuleKeys = [ - projectName: string, - modules: SerializedModuleNode[], +type SerializedEnvironmentModuleNode = [ + id: number, + file: number, + url: number, + importedIds: number[], ] From a4cbef3fd1e41d2c72b52cc55003698a4e5ab955 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Wed, 25 Feb 2026 16:11:13 +0900 Subject: [PATCH 02/15] test: copied from https://github.com/vitest-dev/vitest/pull/9728 --- packages/vitest/src/node/reporters/blob.ts | 2 +- .../merge-reports-module-graph/basic.test.ts | 8 ++ .../merge-reports-module-graph/second.test.ts | 6 ++ .../merge-reports-module-graph/sub/format.ts | 5 ++ .../merge-reports-module-graph/sub/subject.ts | 3 + .../merge-reports-module-graph/util.ts | 5 ++ .../vitest.config.js | 1 + test/cli/test/reporters/merge-reports.test.ts | 84 ++++++++++++++++++- 8 files changed, 112 insertions(+), 2 deletions(-) create mode 100644 test/cli/fixtures/reporters/merge-reports-module-graph/basic.test.ts create mode 100644 test/cli/fixtures/reporters/merge-reports-module-graph/second.test.ts create mode 100644 test/cli/fixtures/reporters/merge-reports-module-graph/sub/format.ts create mode 100644 test/cli/fixtures/reporters/merge-reports-module-graph/sub/subject.ts create mode 100644 test/cli/fixtures/reporters/merge-reports-module-graph/util.ts create mode 100644 test/cli/fixtures/reporters/merge-reports-module-graph/vitest.config.js diff --git a/packages/vitest/src/node/reporters/blob.ts b/packages/vitest/src/node/reporters/blob.ts index 9beed554b298..e0ed34b2d06f 100644 --- a/packages/vitest/src/node/reporters/blob.ts +++ b/packages/vitest/src/node/reporters/blob.ts @@ -286,7 +286,7 @@ type MergeReport = [ environmentModules: MergeReportEnvironmentModules, ] -type MergeReportEnvironmentModules = { +interface MergeReportEnvironmentModules { [projectName: string]: { [environmentName: string]: { idTable: string[] diff --git a/test/cli/fixtures/reporters/merge-reports-module-graph/basic.test.ts b/test/cli/fixtures/reporters/merge-reports-module-graph/basic.test.ts new file mode 100644 index 000000000000..c567ed10f00b --- /dev/null +++ b/test/cli/fixtures/reporters/merge-reports-module-graph/basic.test.ts @@ -0,0 +1,8 @@ +import { test, expect } from 'vitest' +import { formatHello } from './sub/format' +import { hello } from './util' + +test('passes', () => { + expect(hello()).toBe('Hello, graph!') + expect(formatHello()).toBe('Hello, graph!') +}) diff --git a/test/cli/fixtures/reporters/merge-reports-module-graph/second.test.ts b/test/cli/fixtures/reporters/merge-reports-module-graph/second.test.ts new file mode 100644 index 000000000000..538861171d37 --- /dev/null +++ b/test/cli/fixtures/reporters/merge-reports-module-graph/second.test.ts @@ -0,0 +1,6 @@ +import { test, expect } from 'vitest' +import { hello } from './util' + +test('also passes', () => { + expect(hello()).toBe('Hello, graph!') +}) diff --git a/test/cli/fixtures/reporters/merge-reports-module-graph/sub/format.ts b/test/cli/fixtures/reporters/merge-reports-module-graph/sub/format.ts new file mode 100644 index 000000000000..f653729ee14b --- /dev/null +++ b/test/cli/fixtures/reporters/merge-reports-module-graph/sub/format.ts @@ -0,0 +1,5 @@ +import { getSubject } from './subject' + +export function formatHello() { + return `Hello, ${getSubject()}!` +} diff --git a/test/cli/fixtures/reporters/merge-reports-module-graph/sub/subject.ts b/test/cli/fixtures/reporters/merge-reports-module-graph/sub/subject.ts new file mode 100644 index 000000000000..7a7bc073b1c3 --- /dev/null +++ b/test/cli/fixtures/reporters/merge-reports-module-graph/sub/subject.ts @@ -0,0 +1,3 @@ +export function getSubject() { + return 'graph' +} diff --git a/test/cli/fixtures/reporters/merge-reports-module-graph/util.ts b/test/cli/fixtures/reporters/merge-reports-module-graph/util.ts new file mode 100644 index 000000000000..49981c185118 --- /dev/null +++ b/test/cli/fixtures/reporters/merge-reports-module-graph/util.ts @@ -0,0 +1,5 @@ +import { getSubject } from './sub/subject' + +export function hello() { + return `Hello, ${getSubject()}!` +} diff --git a/test/cli/fixtures/reporters/merge-reports-module-graph/vitest.config.js b/test/cli/fixtures/reporters/merge-reports-module-graph/vitest.config.js new file mode 100644 index 000000000000..b1c6ea436a54 --- /dev/null +++ b/test/cli/fixtures/reporters/merge-reports-module-graph/vitest.config.js @@ -0,0 +1 @@ +export default {} diff --git a/test/cli/test/reporters/merge-reports.test.ts b/test/cli/test/reporters/merge-reports.test.ts index e52f6c0e2129..38f1c4777e5d 100644 --- a/test/cli/test/reporters/merge-reports.test.ts +++ b/test/cli/test/reporters/merge-reports.test.ts @@ -6,6 +6,7 @@ import { createFileTask } from '@vitest/runner/utils' import { beforeEach, expect, test } from 'vitest' import { version } from 'vitest/package.json' import { writeBlob } from 'vitest/src/node/reporters/blob.js' +import { getModuleGraph } from '../../../../packages/vitest/src/utils/graph' // always relative to CWD because it's used only from the CLI, // so we need to correctly resolve it here @@ -254,7 +255,7 @@ test('total and merged execution times are shown', async () => { file.tasks.push(createTest('some test', file)) await writeBlob( - [version, [file], [], [], undefined, 1500 * index], + [version, [file], [], undefined, 1500 * index, {}], resolve(`./fixtures/reporters/merge-reports/.vitest-reports/blob-${index}-2.json`), ) } @@ -272,6 +273,87 @@ test('total and merged execution times are shown', async () => { expect(stdout).toContain('Per blob 1.50s 3.00s') }) +test('module graph available', async () => { + const root = resolve('./fixtures/reporters/merge-reports-module-graph') + const reportsDir = resolve(root, '.vitest-reports') + rmSync(reportsDir, { force: true, recursive: true }) + + // generate blob + await runVitest({ + root, + reporters: ['blob'], + }) + + // test restored blob has module graph + const { stderr, ctx } = await runVitest({ + root, + mergeReports: reportsDir, + }) + expect(stderr).toMatchInlineSnapshot(`""`) + expect.assert(ctx) + // TODO: test with getModuleGraph + expect.assert(getModuleGraph) + // const moduleGraphJson = JSON.stringify(ctx.state.blobs?.moduleGraphData, null, 2).replaceAll(ctx.config.root, '') + // expect(moduleGraphJson).toMatchInlineSnapshot(` + // "{ + // "": { + // "/basic.test.ts": { + // "graph": { + // "/sub/subject.ts": [], + // "/sub/format.ts": [ + // "/sub/subject.ts" + // ], + // "/util.ts": [ + // "/sub/subject.ts" + // ], + // "/basic.test.ts": [ + // "/sub/format.ts", + // "/util.ts" + // ] + // }, + // "externalized": [], + // "inlined": [ + // "/basic.test.ts", + // "/sub/format.ts", + // "/sub/subject.ts", + // "/util.ts" + // ] + // }, + // "/second.test.ts": { + // "graph": { + // "/sub/subject.ts": [], + // "/util.ts": [ + // "/sub/subject.ts" + // ], + // "/second.test.ts": [ + // "/util.ts" + // ] + // }, + // "externalized": [], + // "inlined": [ + // "/second.test.ts", + // "/util.ts", + // "/sub/subject.ts" + // ] + // } + // } + // }" + // `) + + // also check html reporter doesn't crash + const result = await runVitest({ + root, + mergeReports: resolve(root, '.vitest-reports'), + reporters: ['html'], + }) + expect(result.stderr).toMatchInlineSnapshot(`""`) + expect(result.stdout).toMatchInlineSnapshot(` + " HTML Report is generated + You can run npx vite preview --outDir html to see the test results. + " + `) +}) + function trimReporterOutput(report: string) { const rows = report .replace(/\d+ms/g, '