Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 14 additions & 11 deletions packages/ui/node/reporter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,28 +66,31 @@ export default class HTMLReporter implements Reporter {
}

async onTestRunEnd(): Promise<void> {
const blobs = this.ctx.state.blobs
const result: HTMLReportData = {
paths: this.ctx.state.getPaths(),
files: this.ctx.state.getFiles(),
config: this.ctx.getRootProject().serializedConfig,
unhandledErrors: this.ctx.state.getUnhandledErrors(),
projects: this.ctx.projects.map(p => p.name),
moduleGraph: {},
moduleGraph: blobs?.moduleGraphData ?? {},
sources: {},
}
const promises: Promise<void>[] = []

promises.push(...result.files.map(async (file) => {
const projectName = file.projectName || ''
const resolvedConfig = this.ctx.getProjectByName(projectName).config
const browser = resolvedConfig.browser.enabled
result.moduleGraph[projectName] ??= {}
result.moduleGraph[projectName][file.filepath] = await getModuleGraph(
this.ctx,
projectName,
file.filepath,
browser,
)
if (!blobs) {
const projectName = file.projectName || ''
const resolvedConfig = this.ctx.getProjectByName(projectName).config
const browser = resolvedConfig.browser.enabled
result.moduleGraph[projectName] ??= {}
result.moduleGraph[projectName][file.filepath] = await getModuleGraph(
this.ctx,
projectName,
file.filepath,
browser,
)
}
if (!result.sources[file.filepath]) {
try {
result.sources[file.filepath] = await fs.readFile(file.filepath, {
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 @@ -594,8 +594,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
192 changes: 190 additions & 2 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,33 @@ export class BlobReporter implements Reporter {
},
)

const moduleGraphData: Record<string, Record<string, ModuleGraphData>> = {}
await Promise.all(
files.map(async (file) => {
if (file.pool === 'typescript') {
return
}

const projectName = file.projectName || ''
const project = this.ctx.getProjectByName(projectName)
moduleGraphData[projectName] ??= {}
moduleGraphData[projectName][file.filepath] = await getModuleGraph(
this.ctx,
projectName,
file.filepath,
project.config.browser.enabled,
)
}),
)

const report = [
this.ctx.version,
files,
errors,
modules,
coverage,
executionTime,
encodeModuleGraphData(this.ctx, moduleGraphData),
] satisfies MergeReport

const reportFile = resolve(this.ctx.config.root, outputFile)
Expand Down Expand Up @@ -112,15 +134,15 @@ export async function readBlobs(
)
}
const content = await readFile(fullPath, 'utf-8')
const [version, files, errors, moduleKeys, coverage, executionTime] = parse(
const [version, files, errors, moduleKeys, coverage, executionTime, moduleGraphData] = parse(
content,
) as MergeReport
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, moduleGraphData }
})
const blobs = await Promise.all(promises)

Expand Down Expand Up @@ -178,12 +200,24 @@ export async function readBlobs(
const errors = blobs.flatMap(blob => blob.errors)
const coverages = blobs.map(blob => blob.coverage)
const executionTimes = blobs.map(blob => blob.executionTime)
const moduleGraphData: Record<string, Record<string, ModuleGraphData>> = {}
blobs.forEach((blob) => {
if (!blob.moduleGraphData) {
return
}
const decodedModuleGraphData = decodeModuleGraphData(blob.moduleGraphData)
Object.entries(decodedModuleGraphData).forEach(([projectName, graph]) => {
moduleGraphData[projectName] ??= {}
Object.assign(moduleGraphData[projectName], graph)
})
})

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

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

type MergeReport = [
Expand All @@ -201,6 +236,7 @@ type MergeReport = [
modules: MergeReportModuleKeys[],
coverage: unknown,
executionTime: number,
moduleGraphData: SerializedModuleGraphByProject,
]

type SerializedModuleNode = [
Expand All @@ -213,3 +249,155 @@ type MergeReportModuleKeys = [
projectName: string,
modules: SerializedModuleNode[],
]

interface ModuleGraphDataByProject {
[projectName: string]: {
[testFilePath: string]: ModuleGraphData
}
}

interface SerializedModuleGraphByProject {
[projectName: string]: SerializedProjectModuleGraphData
}

// The goal is to avoid O(testFiles * graphSize) payload growth.
// Instead of serializing full ModuleGraphData for each test file, we:
// 1) store one deduped module id table per project (`idTable`),
// 2) store shared inlined nodes and dependency edges (`inlined` + `edges`), and
// 3) store test file roots as id indexes (`testFiles`).
// On merge read, each file-level ModuleGraphData is reconstructed by
// traversing the shared graph from that file's root index.
// Assumption: test files within the same project share the same
// inlined/externalized classification. This can diverge when one project
// effectively mixes multiple runtime environments (for example node/jsdom).
interface SerializedProjectModuleGraphData {
idTable: string[]
testFiles: number[]
setupFiles: number[]
inlined: number[]
edges: [from: number, to: number][]
}

function encodeModuleGraphData(
ctx: Vitest,
moduleGraphData: ModuleGraphDataByProject,
): SerializedModuleGraphByProject {
const encoded: SerializedModuleGraphByProject = {}

Object.entries(moduleGraphData).forEach(([projectName, projectGraph]) => {
const idTable: string[] = []
const idMap = new Map<string, number>()
const inlinedSet = new Set<number>()
const edgeSet = new Set<string>()

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 projectData: SerializedProjectModuleGraphData = {
idTable,
inlined: [],
edges: [],
testFiles: [],
setupFiles: [],
}

Object.entries(projectGraph).forEach(([filepath, graphData]) => {
const filepathIndex = getIdIndex(filepath)

projectData.testFiles.push(filepathIndex)

graphData.inlined.forEach((moduleId) => {
inlinedSet.add(getIdIndex(moduleId))
})

Object.entries(graphData.graph).forEach(([moduleId, deps]) => {
const from = getIdIndex(moduleId)
deps.forEach((depId) => {
const to = getIdIndex(depId)
const edgeKey = `${from}:${to}`
if (!edgeSet.has(edgeKey)) {
edgeSet.add(edgeKey)
projectData.edges.push([from, to])
}
})
})
})
const setupFiles = ctx.getProjectByName(projectName).config.setupFiles
projectData.setupFiles = setupFiles.map(getIdIndex)

projectData.inlined = [...inlinedSet]
encoded[projectName] = projectData
})

return encoded
}

function decodeModuleGraphData(moduleGraphData: SerializedModuleGraphByProject): ModuleGraphDataByProject {
const decoded: ModuleGraphDataByProject = {}

Object.entries(moduleGraphData).forEach(([projectName, projectData]) => {
decoded[projectName] = {}
const inlinedSet = new Set(projectData.inlined)
const edgeMap = new Map<number, number[]>()

projectData.edges.forEach(([from, to]) => {
const deps = edgeMap.get(from)
if (deps) {
deps.push(to)
}
else {
edgeMap.set(from, [to])
}
})

const decodeFileModuleGraph = (rootId: number): ModuleGraphData => {
const graph: ModuleGraphData['graph'] = {}
const visitedInlined = new Set<number>()
const visitedExternal = new Set<number>()

const visitInlinedModule = (idIndex: number) => {
if (visitedInlined.has(idIndex) || !inlinedSet.has(idIndex)) {
return
}
visitedInlined.add(idIndex)

const deps = edgeMap.get(idIndex) || []
deps.forEach((dep) => {
if (inlinedSet.has(dep)) {
visitInlinedModule(dep)
}
else {
visitedExternal.add(dep)
}
})
graph[projectData.idTable[idIndex]!] = deps.map(dep => projectData.idTable[dep]!)
}

visitInlinedModule(rootId)
projectData.setupFiles.forEach((setupId) => {
visitInlinedModule(setupId)
})

return {
graph,
externalized: [...visitedExternal].map(index => projectData.idTable[index]!),
inlined: [...visitedInlined].map(index => projectData.idTable[index]!),
}
}

projectData.testFiles.forEach((filepathIndex) => {
const filepath = projectData.idTable[filepathIndex]!
decoded[projectName][filepath] = decodeFileModuleGraph(filepathIndex)
})
})

return decoded
}
Original file line number Diff line number Diff line change
@@ -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!')
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { test, expect } from 'vitest'
import { hello } from './util'

test('also passes', () => {
expect(hello()).toBe('Hello, graph!')
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { getSubject } from './subject'

export function formatHello() {
return `Hello, ${getSubject()}!`
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export function getSubject() {
return 'graph'
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { getSubject } from './sub/subject'

export function hello() {
return `Hello, ${getSubject()}!`
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export default {}
Loading
Loading