diff --git a/packages/vitest/src/node/reporters/blob.ts b/packages/vitest/src/node/reporters/blob.ts index e2e89ce47985..32dd65948bd7 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 { DevEnvironment, EnvironmentModuleNode } from 'vite' import type { Vitest } from '../core' import type { TestProject } from '../project' import type { Reporter } from '../types/reporter' @@ -54,27 +55,40 @@ 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 - } - return [mod[0], mod[1].file, mod[1].url] - }).filter(x => x != null), - ] - }, - ) + const environmentModules: MergeReportEnvironmentModules = {} + this.ctx.projects.forEach((project) => { + const serializedProject: MergeReportEnvironmentModules[string] = { + environments: {}, + external: [], + } + Object.entries(project.vite.environments).forEach(([environmentName, environment]) => { + serializedProject.environments[environmentName] = serializeEnvironmentModuleGraph( + environment, + ) + }) + + if (project.browser?.vite.environments.client) { + serializedProject.browser = serializeEnvironmentModuleGraph( + project.browser.vite.environments.client, + ) + } + + for (const [id, value] of project._resolver.externalizeCache.entries()) { + if (typeof value === 'string') { + serializedProject.external.push([id, value]) + } + } + + environmentModules[project.name] = serializedProject + }) const report = [ this.ctx.version, files, errors, - modules, coverage, executionTime, + environmentModules, ] satisfies MergeReport const reportFile = resolve(this.ctx.config.root, outputFile) @@ -112,7 +126,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 +134,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,28 +157,38 @@ export async function readBlobs( ) } - // fake module graph - it is used to check if module is imported, but we don't use values inside + // Restore module graph const projects = Object.fromEntries( projectsArray.map(p => [p.name, p]), ) + for (const project of projectsArray) { + if (project.isBrowserEnabled()) { + await project._initBrowserServer() + } + } + blobs.forEach((blob) => { - blob.moduleKeys.forEach(([projectName, moduleIds]) => { + Object.entries(blob.environmentModules).forEach(([projectName, modulesByProject]) => { 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, - } - project.vite.moduleGraph.idToModuleMap.set(moduleId, moduleNode) + + modulesByProject.external.forEach(([id, externalized]) => { + project._resolver.externalizeCache.set(id, externalized) }) + + Object.entries(modulesByProject.environments).forEach(([environmentName, moduleGraph]) => { + const environment = project.vite.environments[environmentName] + deserializeEnvironmentModuleGraph(environment, moduleGraph) + }) + + const browserModuleGraph = modulesByProject.browser + if (browserModuleGraph) { + const browserEnvironment = project.browser!.vite.environments.client + deserializeEnvironmentModuleGraph(browserEnvironment, browserModuleGraph) + } }) }) @@ -198,18 +222,107 @@ type MergeReport = [ vitestVersion: string, files: File[], errors: unknown[], - modules: MergeReportModuleKeys[], coverage: unknown, executionTime: number, + environmentModules: MergeReportEnvironmentModules, ] -type SerializedModuleNode = [ - id: string, - file: string, - url: string, -] +interface MergeReportEnvironmentModules { + [projectName: string]: { + environments: { + [environmentName: string]: SerializedEnvironmentModuleGraph + } + browser?: SerializedEnvironmentModuleGraph + external: [id: string, externalized: string][] + } +} -type MergeReportModuleKeys = [ - projectName: string, - modules: SerializedModuleNode[], +type SerializedEnvironmentModuleNode = [ + id: number, + file: number, + url: number, + importedIds: number[], ] + +interface SerializedEnvironmentModuleGraph { + idTable: string[] + modules: SerializedEnvironmentModuleNode[] +} + +function serializeEnvironmentModuleGraph( + environment: DevEnvironment, +): SerializedEnvironmentModuleGraph { + 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)) + } + } + + modules.push([ + getIdIndex(id), + getIdIndex(mod.file), + getIdIndex(mod.url), + importedIds, + ]) + } + + return { + idTable, + modules, + } +} + +function deserializeEnvironmentModuleGraph( + environment: DevEnvironment, + serialized: SerializedEnvironmentModuleGraph, +): void { + const nodesById = new Map() + + serialized.modules.forEach(([id, file, url]) => { + const moduleId = serialized.idTable[id] + const filePath = serialized.idTable[file] + const urlPath = serialized.idTable[url] + const moduleNode = environment.moduleGraph.createFileOnlyEntry(filePath) + moduleNode.url = urlPath + moduleNode.id = moduleId + moduleNode.transformResult = { + // print error checks that transformResult is set + code: ' ', + map: null, + } + environment.moduleGraph.idToModuleMap.set(moduleId, moduleNode) + nodesById.set(moduleId, moduleNode) + }) + + serialized.modules.forEach(([id, _file, _url, importedIds]) => { + const moduleId = serialized.idTable[id] + const moduleNode = nodesById.get(moduleId)! + importedIds.forEach((importedIdIndex) => { + const importedId = serialized.idTable[importedIdIndex] + const importedNode = nodesById.get(importedId)! + moduleNode.importedModules.add(importedNode) + importedNode.importers.add(moduleNode) + }) + }) +} diff --git a/packages/vitest/src/node/resolver.ts b/packages/vitest/src/node/resolver.ts index 09443df349ae..94670766646d 100644 --- a/packages/vitest/src/node/resolver.ts +++ b/packages/vitest/src/node/resolver.ts @@ -12,7 +12,7 @@ import { isWindows } from '../utils/env' export class VitestResolver { public readonly options: ExternalizeOptions private externalizeConcurrentCache = new Map>() - private externalizeCache = new Map() + public externalizeCache: Map = new Map() constructor(cacheDir: string, config: ResolvedConfig) { // sorting to make cache consistent 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..b08172727353 --- /dev/null +++ b/test/cli/fixtures/reporters/merge-reports-module-graph/second.test.ts @@ -0,0 +1,11 @@ +import { test, expect } from 'vitest' +import { hello } from './util' +import * as obug from "obug" + +test('also passes', () => { + expect(hello()).toBe('Hello, graph!') +}) + +test('external', () => { + expect(obug).toBeTypeOf('object') +}) 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..94a64e2f78ac --- /dev/null +++ b/test/cli/fixtures/reporters/merge-reports-module-graph/vitest.config.js @@ -0,0 +1,3 @@ +import { defineConfig } from "vitest/config" + +export default defineConfig({}) diff --git a/test/cli/test/reporters/merge-reports.test.ts b/test/cli/test/reporters/merge-reports.test.ts index e52f6c0e2129..2f445b5c20f9 100644 --- a/test/cli/test/reporters/merge-reports.test.ts +++ b/test/cli/test/reporters/merge-reports.test.ts @@ -1,11 +1,14 @@ import type { File, Test } from '@vitest/runner/types' +import type { TestUserConfig, Vitest } from 'vitest/node' import { rmSync } from 'node:fs' import { resolve } from 'node:path' import { runVitest } from '#test-utils' +import { playwright } from '@vitest/browser-playwright' 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 +257,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 +275,179 @@ test('total and merged execution times are shown', async () => { expect(stdout).toContain('Per blob 1.50s 3.00s') }) +test.for([ + 'node', + 'browser', +])('module graph and html reporter $0', async (mode) => { + const root = resolve('./fixtures/reporters/merge-reports-module-graph') + const reportsDir = resolve(root, '.vitest-reports') + rmSync(reportsDir, { force: true, recursive: true }) + + const baseConfig: TestUserConfig = { + root, + } + if (mode === 'browser') { + baseConfig.browser = { + enabled: true, + provider: playwright(), + instances: [ + { + browser: 'chromium', + }, + ], + headless: true, + } + } + + const result = await runVitest({ + ...baseConfig, + reporters: ['blob'], + }) + expect.assert(result.ctx) + const generatedModuleGraphJson = await getSerializedModuleGraph(result.ctx) + if (mode === 'browser') { + expect(generatedModuleGraphJson).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", + "/obug.js" + ] + }, + "externalized": [ + "/obug.js?v=" + ], + "inlined": [ + "/second.test.ts", + "/util.ts", + "/sub/subject.ts" + ] + } + }" + `) + } + else { + expect(generatedModuleGraphJson).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", + "/obug/dist/node.js" + ] + }, + "externalized": [ + "/obug/dist/node.js" + ], + "inlined": [ + "/second.test.ts", + "/util.ts", + "/sub/subject.ts" + ] + } + }" + `) + } + + const result2 = await runVitest({ + ...baseConfig, + mergeReports: reportsDir, + }) + expect(result2.stderr).toMatchInlineSnapshot(`""`) + expect.assert(result2.ctx) + const restoredModuleGraphJson = await getSerializedModuleGraph(result2.ctx) + expect(restoredModuleGraphJson).toBe(generatedModuleGraphJson) + + const result3 = await runVitest({ + ...baseConfig, + mergeReports: resolve(root, '.vitest-reports'), + reporters: ['html'], + }) + expect(result3.stderr).toMatchInlineSnapshot(`""`) + expect(result3.stdout).toMatchInlineSnapshot(` + " HTML Report is generated + You can run npx vite preview --outDir html to see the test results. + " + `) +}) + +async function getSerializedModuleGraph(ctx: Vitest) { + const files = ctx.state.getFiles().slice().sort((a, b) => a.filepath.localeCompare(b.filepath)) + const moduleGraphs = Object.fromEntries( + await Promise.all( + files.map(async (file) => { + const projectName = file.projectName || '' + const project = ctx.getProjectByName(projectName) + const graph = await getModuleGraph( + ctx, + projectName, + file.filepath, + project.config.browser.enabled, + ) + return [file.filepath, graph] as const + }), + ), + ) + return JSON.stringify(moduleGraphs, null, 2) + .replaceAll(ctx.config.root, '') + .replace(/"[^"\n]*\/node_modules\//g, '"/') + .replace(/\/\.vite\/vitest\/[a-f0-9]{40}\/deps\/([^"?]+)/g, '/$1') + .replace(/\?v=[a-f0-9]+/g, '?v=') +} + function trimReporterOutput(report: string) { const rows = report .replace(/\d+ms/g, '