Skip to content
7 changes: 7 additions & 0 deletions packages/vitest/src/api/setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,13 @@ export function setup(ctx: Vitest, _server?: ViteDevServer): void {
return result
},
async getModuleGraph(project, id, browser): Promise<ModuleGraphData> {
// If we're in merge-reports mode and have cached module graph data, return it
if (ctx.state.blobs?.moduleGraphData) {
const cachedData = ctx.state.blobs.moduleGraphData[project]?.[id]
if (cachedData) {
return cachedData
}
}
return getModuleGraph(ctx, project, id, browser)
},
async updateSnapshot(file?: File) {
Expand Down
4 changes: 2 additions & 2 deletions packages/vitest/src/node/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -542,8 +542,8 @@ export class Vitest {
throw new Error('Cannot merge reports when `--reporter=blob` is used. Remove blob reporter from the config first.')
}

const { files, errors, coverages, executionTimes } = await readBlobs(this.version, directory || this.config.mergeReports, this.projects)
this.state.blobs = { files, errors, coverages, executionTimes }
const { files, errors, coverages, executionTimes, moduleGraphData } = await readBlobs(this.version, directory || this.config.mergeReports, this.projects)
this.state.blobs = { files, errors, coverages, executionTimes, moduleGraphData }

await this.report('onInit', this)

Expand Down
114 changes: 110 additions & 4 deletions packages/vitest/src/node/reporters/blob.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { File } from '@vitest/runner'
import type { SerializedError } from '@vitest/utils'
import type { ModuleGraphData } from '../../types/general'
import type { Vitest } from '../core'
import type { TestProject } from '../project'
import type { Reporter } from '../types/reporter'
Expand All @@ -9,6 +10,7 @@ import { mkdir, readdir, readFile, stat, writeFile } from 'node:fs/promises'
import { parse, stringify } from 'flatted'
import { dirname, resolve } from 'pathe'
import { getOutputFile } from '../../utils/config-helpers'
import { getModuleGraph } from '../../utils/graph'

export interface BlobOptions {
outputFile?: string
Expand Down Expand Up @@ -68,13 +70,66 @@ export class BlobReporter implements Reporter {
},
)

// Build module ID to index map for efficient graph storage
const moduleIdToIndex = new Map<string, number>()
const projectModuleOffsets = new Map<string, number>()
let globalModuleIndex = 0

modules.forEach(([projectName, projectModules]) => {
projectModuleOffsets.set(projectName, globalModuleIndex)
projectModules.forEach((mod) => {
moduleIdToIndex.set(mod[0], globalModuleIndex++)
})
})

// Capture module graph relationships using indices instead of full IDs
const graphData: Record<string, number[][]> = {}
await Promise.all(
files.map(async (file) => {
const projectName = file.projectName || ''
const project = this.ctx.getProjectByName(projectName)
const browser = project.config.browser.enabled

try {
const moduleGraph = await getModuleGraph(
Comment thread
sheremet-va marked this conversation as resolved.
Outdated
this.ctx,
projectName,
file.filepath,
browser,
)

// Convert graph to use indices instead of module IDs
const fileGraphEdges: number[][] = []
Object.entries(moduleGraph.graph).forEach(([moduleId, deps]) => {
const sourceIdx = moduleIdToIndex.get(moduleId)
if (sourceIdx !== undefined) {
deps.forEach((depId) => {
const targetIdx = moduleIdToIndex.get(depId)
if (targetIdx !== undefined) {
fileGraphEdges.push([sourceIdx, targetIdx])
}
})
}
})

graphData[file.filepath] = fileGraphEdges
}
catch (error) {
// If module graph generation fails, use empty graph
this.ctx.logger.error('Failed to generate module graph for', file.filepath, error)
graphData[file.filepath] = []
}
}),
)

const report = [
this.ctx.version,
files,
errors,
modules,
coverage,
executionTime,
graphData,
] satisfies MergeReport

const reportFile = resolve(this.ctx.config.root, outputFile)
Expand Down Expand Up @@ -112,15 +167,14 @@ export async function readBlobs(
)
}
const content = await readFile(fullPath, 'utf-8')
const [version, files, errors, moduleKeys, coverage, executionTime] = parse(
content,
) as MergeReport
const parsed = parse(content) as MergeReport
const [version, files, errors, moduleKeys, coverage, executionTime, graphData] = parsed
if (!version) {
throw new TypeError(
`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, moduleKeys, coverage, file: filename, executionTime, graphData: graphData || {} }
})
const blobs = await Promise.all(promises)

Expand Down Expand Up @@ -148,6 +202,14 @@ export async function readBlobs(
projectsArray.map(p => [p.name, p]),
)

// Build a global module index to ID map
const allModules: SerializedModuleNode[] = []
blobs.forEach((blob) => {
blob.moduleKeys.forEach(([_projectName, moduleIds]) => {
allModules.push(...moduleIds)
})
})

blobs.forEach((blob) => {
blob.moduleKeys.forEach(([projectName, moduleIds]) => {
const project = projects[projectName]
Expand Down Expand Up @@ -179,11 +241,53 @@ export async function readBlobs(
const coverages = blobs.map(blob => blob.coverage)
const executionTimes = blobs.map(blob => blob.executionTime)

// Reconstruct module graph data from indexed edges
const moduleGraphData: Record<string, Record<string, ModuleGraphData>> = {}
blobs.forEach((blob) => {
Object.entries(blob.graphData).forEach(([filepath, edges]) => {
// Find the project for this file
const file = blob.files.find(f => f.filepath === filepath)
if (!file) {
return
}
const projectName = file.projectName || ''

moduleGraphData[projectName] ??= {}

// Convert edges back to ModuleGraphData format
const graph: Record<string, string[]> = {}
const inlinedSet = new Set<string>()
const externalizedSet = new Set<string>()

edges.forEach(([sourceIdx, targetIdx]) => {
const sourceModule = allModules[sourceIdx]
const targetModule = allModules[targetIdx]
if (sourceModule && targetModule) {
const sourceId = sourceModule[0]
const targetId = targetModule[0]
if (!graph[sourceId]) {
graph[sourceId] = []
}
graph[sourceId].push(targetId)
inlinedSet.add(sourceId)
inlinedSet.add(targetId)
}
})

moduleGraphData[projectName][filepath] = {
graph,
externalized: Array.from(externalizedSet),
inlined: Array.from(inlinedSet),
}
})
})

return {
files,
errors,
coverages,
executionTimes,
moduleGraphData,
}
}

Expand All @@ -192,6 +296,7 @@ export interface MergedBlobs {
errors: unknown[]
coverages: unknown[]
executionTimes: number[]
moduleGraphData: Record<string, Record<string, ModuleGraphData>>
}

type MergeReport = [
Expand All @@ -201,6 +306,7 @@ type MergeReport = [
modules: MergeReportModuleKeys[],
coverage: unknown,
executionTime: number,
graphData?: Record<string, number[][]>,
]

type SerializedModuleNode = [
Expand Down
72 changes: 71 additions & 1 deletion test/cli/test/reporters/merge-reports.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { runVitest } from '#test-utils'
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 { readBlobs, writeBlob } from 'vitest/src/node/reporters/blob.js'

// always relative to CWD because it's used only from the CLI,
// so we need to correctly resolve it here
Expand Down Expand Up @@ -267,6 +267,76 @@ test('total and merged execution times are shown', async () => {
expect(stdout).toContain('Per blob 1.50s 3.00s')
})

test('module graph data is stored in blob and restored', async () => {
// Create blob reports with module graph data
await runVitest({
root: './fixtures/reporters/merge-reports',
include: ['first.test.ts'],
reporters: [['blob', { outputFile: './.vitest-reports/first-run.json' }]],
})

await runVitest({
root: './fixtures/reporters/merge-reports',
include: ['second.test.ts'],
reporters: [['blob', { outputFile: './.vitest-reports/second-run.json' }]],
})

// Read blobs and verify module graph data is present
const { moduleGraphData, files } = await readBlobs(
version,
reportsDir,
[] as any, // We don't need actual projects for this test
)

// Verify module graph data exists
expect(moduleGraphData).toBeDefined()
expect(typeof moduleGraphData).toBe('object')

// Verify each test file has module graph data
const testFiles = files.filter(f => f.filepath.endsWith('.test.ts'))
for (const file of testFiles) {
const projectName = file.projectName || ''
const graphData = moduleGraphData[projectName]?.[file.filepath]

expect(graphData).toBeDefined()
expect(graphData).toHaveProperty('graph')
expect(graphData).toHaveProperty('externalized')
expect(graphData).toHaveProperty('inlined')
expect(Array.isArray(graphData.externalized)).toBe(true)
expect(Array.isArray(graphData.inlined)).toBe(true)
expect(typeof graphData.graph).toBe('object')
}
})

test('backward compatibility: blobs without module graph data still work', async () => {
// Create a blob report in the old format (without module graph data)
const file = createFileTask(
resolve('./fixtures/reporters/merge-reports', 'first.test.ts'),
resolve('./fixtures/reporters/merge-reports'),
'',
)
file.tasks.push(createTest('some test', file))

// Write blob in old format (without module graph data - only 6 elements)
await writeBlob(
[version, [file], [], [], undefined, 1000] as any,
resolve('./fixtures/reporters/merge-reports/.vitest-reports/blob-old.json'),
)

// Read blobs should handle missing module graph data gracefully
const { moduleGraphData, files } = await readBlobs(
version,
reportsDir,
[] as any,
)

expect(files).toHaveLength(1)
expect(moduleGraphData).toBeDefined()
expect(typeof moduleGraphData).toBe('object')
// Old blobs should result in empty module graph data
expect(Object.keys(moduleGraphData).length).toBe(0)
})

function trimReporterOutput(report: string) {
const rows = report
.replace(/\d+ms/g, '<time>')
Expand Down