diff --git a/packages/ui/node/reporter.ts b/packages/ui/node/reporter.ts index 7c5a03483912..620c44c8ee37 100644 --- a/packages/ui/node/reporter.ts +++ b/packages/ui/node/reporter.ts @@ -66,28 +66,31 @@ export default class HTMLReporter implements Reporter { } async onTestRunEnd(): Promise { + 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[] = [] 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, { diff --git a/packages/vitest/src/node/core.ts b/packages/vitest/src/node/core.ts index 25d54c31016f..c1e3111500e2 100644 --- a/packages/vitest/src/node/core.ts +++ b/packages/vitest/src/node/core.ts @@ -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) diff --git a/packages/vitest/src/node/reporters/blob.ts b/packages/vitest/src/node/reporters/blob.ts index e2e89ce47985..30aa8076e48b 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 { ModuleGraphData } from '../../types/general' import type { Vitest } from '../core' import type { TestProject } from '../project' import type { Reporter } from '../types/reporter' @@ -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 @@ -68,6 +70,25 @@ export class BlobReporter implements Reporter { }, ) + const moduleGraphData: Record> = {} + 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, @@ -75,6 +96,7 @@ export class BlobReporter implements Reporter { modules, coverage, executionTime, + encodeModuleGraphData(this.ctx, moduleGraphData), ] satisfies MergeReport const reportFile = resolve(this.ctx.config.root, outputFile) @@ -112,7 +134,7 @@ 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) { @@ -120,7 +142,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, moduleKeys, coverage, file: filename, executionTime, moduleGraphData } }) const blobs = await Promise.all(promises) @@ -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> = {} + 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, } } @@ -192,6 +226,7 @@ export interface MergedBlobs { errors: unknown[] coverages: unknown[] executionTimes: number[] + moduleGraphData: Record> } type MergeReport = [ @@ -201,6 +236,7 @@ type MergeReport = [ modules: MergeReportModuleKeys[], coverage: unknown, executionTime: number, + moduleGraphData: SerializedModuleGraphByProject, ] type SerializedModuleNode = [ @@ -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() + const inlinedSet = new Set() + const edgeSet = new Set() + + 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() + + 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() + const visitedExternal = new Set() + + 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 +} 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..7d5718e8ebc4 100644 --- a/test/cli/test/reporters/merge-reports.test.ts +++ b/test/cli/test/reporters/merge-reports.test.ts @@ -254,7 +254,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 +272,85 @@ 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) + 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, '