diff --git a/packages/ui/client/composables/client/static.ts b/packages/ui/client/composables/client/static.ts index 33c22e37e510..8627a4f177f5 100644 --- a/packages/ui/client/composables/client/static.ts +++ b/packages/ui/client/composables/client/static.ts @@ -1,14 +1,16 @@ -import type { ModuleGraphData, RunnerTestFile, SerializedRootConfig } from 'vitest' +import type { RunnerTestFile, SerializedRootConfig } from 'vitest' +import type { SerializedProjectModules } from '../../../../vitest/src/utils/module-graph/types' import type { VitestClient, VitestClientRpc } from './ws' import { decompressSync, strFromU8 } from 'fflate' import { parse } from 'flatted' import { reactive } from 'vue' +import { getSerializedTestFileModuleGraph } from '../../../../vitest/src/utils/module-graph/serialized' import { StateManager } from './state' export interface HTMLReportMetadata { files: RunnerTestFile[] config: SerializedRootConfig - moduleGraph: Record> + environmentModules: Record unhandledErrors: unknown[] testModules: { projectName: string @@ -38,8 +40,12 @@ function deserializeReportMetadata(metadata: HTMLReportMetadata) { getConfig: async () => { return metadata.config }, - getModuleGraph: async (projectName, id) => { - return metadata.moduleGraph[projectName]?.[id] + getModuleGraph: async (projectName, id, viteEnvironment) => { + return getSerializedTestFileModuleGraph( + metadata.environmentModules[projectName], + id, + viteEnvironment, + ) }, getUnhandledErrors: async () => { return metadata.unhandledErrors diff --git a/packages/ui/node/reporter.ts b/packages/ui/node/reporter.ts index 3cec227ee0e3..ac5048011879 100644 --- a/packages/ui/node/reporter.ts +++ b/packages/ui/node/reporter.ts @@ -14,7 +14,7 @@ import { gzip, constants as zlibConstants } from 'node:zlib' import { stringify } from 'flatted' import { dirname, relative, resolve } from 'pathe' import c from 'tinyrainbow' -import { getModuleGraph } from '../../vitest/src/utils/graph' +import { serializeProjectModules } from '../../vitest/src/node/module-graph' import { distClientRoot } from './paths' const gzipAsync = promisify(gzip) @@ -120,7 +120,7 @@ async function serializeReportMetadata( files: [], config: ctx.serializedRootConfig, unhandledErrors: [...unhandledErrors], - moduleGraph: {}, + environmentModules: {}, testModules: [], sourceCode: { codeTable: [], @@ -146,8 +146,9 @@ async function serializeReportMetadata( result.sourceCode.codeTable.push(code) return index } - - const promises: Promise[] = [] + ctx.projects.forEach((project) => { + result.environmentModules[project.name] = serializeProjectModules(project) + }) for (const testModule of testModules) { result.files.push(testModule.task) @@ -167,23 +168,8 @@ async function serializeReportMetadata( testModuleCodes[projectName][testModule.relativeModuleId] = getCodeIndex(code) } catch {} } - - // TODO: https://github.com/vitest-dev/vitest/issues/9763 - promises.push( - (async () => { - result.moduleGraph[projectName] ??= {} - result.moduleGraph[projectName][testModule.moduleId] = await getModuleGraph( - ctx, - projectName, - testModule.moduleId, - testModule.viteEnvironment?.name, - ) - })(), - ) } - await Promise.all(promises) - return result } diff --git a/packages/vitest/src/api/setup.ts b/packages/vitest/src/api/setup.ts index 79d441e49fc3..98cfe7b093fc 100644 --- a/packages/vitest/src/api/setup.ts +++ b/packages/vitest/src/api/setup.ts @@ -28,9 +28,9 @@ import { createBirpc } from 'birpc' import { parse, stringify } from 'flatted' import { WebSocketServer } from 'ws' import { API_PATH } from '../constants' +import { getModuleGraph } from '../node/module-graph' import { isFileServingAllowed } from '../node/vite' import { getTestFileEnvironment } from '../utils/environments' -import { getModuleGraph } from '../utils/graph' import { stringifyReplace } from '../utils/serialization' import { isValidApiRequest } from './check' diff --git a/packages/vitest/src/node/module-graph.ts b/packages/vitest/src/node/module-graph.ts new file mode 100644 index 000000000000..5a46214e33e5 --- /dev/null +++ b/packages/vitest/src/node/module-graph.ts @@ -0,0 +1,159 @@ +import type { DevEnvironment, EnvironmentModuleNode } from 'vite' +import type { ModuleGraphData } from '../types/general' +import type { + SerializedEnvironmentModuleGraph, + SerializedEnvironmentModuleNode, + SerializedProjectModules, +} from '../utils/module-graph/types' +import type { Vitest } from './core' +import type { TestProject } from './project' +import { getTestFileEnvironment } from '../utils/environments' +import { getTestFileModuleGraph } from '../utils/module-graph/query' + +export async function getModuleGraph( + ctx: Vitest, + projectName: string, + testFilePath: string, + viteEnvironment?: string, +): Promise { + const project = ctx.getProjectByName(projectName) + const browser = project.config.browser.enabled + + let environment: DevEnvironment | undefined + + if (viteEnvironment) { + environment = project.vite.environments[viteEnvironment] + } else { + environment = + project.config.experimental.viteModuleRunner === false + ? project.vite.environments.__vitest__ + : getTestFileEnvironment(project, testFilePath, browser) + } + + if (!environment) { + throw new Error(`Cannot find environment for ${testFilePath}`) + } + return getTestFileModuleGraph(project, environment, testFilePath) +} + +export function serializeProjectModules(project: TestProject): SerializedProjectModules { + const serialized: SerializedProjectModules = { + environments: {}, + external: [], + project: { + setupFiles: project.config.setupFiles, + }, + } + + Object.entries(project.vite.environments).forEach(([environmentName, environment]) => { + serialized.environments[environmentName] = serializeEnvironmentModuleGraph(environment) + }) + + if (project.config.experimental.viteModuleRunner === false) { + serialized.project.defaultEnvironment = '__vitest__' + } else if (project.config.browser.enabled) { + serialized.project.defaultEnvironment = 'client' + serialized.project.browserCacheDir = project.browser?.vite.config.cacheDir + } + + for (const [id, value] of project._resolver.externalizeCache.entries()) { + if (typeof value === 'string') { + serialized.external.push([id, value]) + } + } + + return serialized +} + +export function restoreProjectModules( + project: TestProject, + serialized: SerializedProjectModules, +): void { + serialized.external.forEach(([id, externalized]) => { + project._resolver.externalizeCache.set(id, externalized) + }) + + Object.entries(serialized.environments).forEach(([environmentName, moduleGraph]) => { + const environment = project.vite.environments[environmentName] + deserializeEnvironmentModuleGraph(environment, moduleGraph) + }) +} + +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()) { + // Vite can generate module with `file = ""` for module id "#..." + // when the actual module doesn't exist (e.g. resolve failure or mocked module) + if (mod.file == null) { + continue + } + + const importedIds: number[] = [] + for (const importedNode of mod.importedModules) { + if (importedNode.id !== null) { + 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] + // `createFileOnlyEntry('')` normalizes the file to ".". This keeps + // the graph usable, but doesn't perfectly round-trip Vite's `file = ""` + // nodes for ids like "#...". + // We may just do moduleNode.file = filePath in the future. + 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/reporters/blob.ts b/packages/vitest/src/node/reporters/blob.ts index 64134c377a16..5dd951069a9d 100644 --- a/packages/vitest/src/node/reporters/blob.ts +++ b/packages/vitest/src/node/reporters/blob.ts @@ -1,6 +1,6 @@ import type { SerializedError } from '@vitest/utils' -import type { DevEnvironment, EnvironmentModuleNode } from 'vite' import type { File } from '../../runtime/runner/types' +import type { SerializedProjectModules } from '../../utils/module-graph/types' import type { Vitest } from '../core' import type { TestProject } from '../project' import type { Reporter } from '../types/reporter' @@ -11,6 +11,7 @@ import { sanitizeFilePath } from '@vitest/utils/helpers' import { parse, stringify } from 'flatted' import { dirname, resolve } from 'pathe' import { getOutputFile } from '../../utils/config-helpers' +import { restoreProjectModules, serializeProjectModules } from '../module-graph' export interface BlobOptions { outputFile?: string @@ -53,22 +54,7 @@ export class BlobReporter implements Reporter { 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) - }) - - for (const [id, value] of project._resolver.externalizeCache.entries()) { - if (typeof value === 'string') { - serializedProject.external.push([id, value]) - } - } - - environmentModules[project.name] = serializedProject + environmentModules[project.name] = serializeProjectModules(project) }) const content = stringify([ @@ -171,14 +157,7 @@ export async function readBlobs( return } - 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) - }) + restoreProjectModules(project, modulesByProject) }) }) @@ -218,101 +197,5 @@ export type MergeReport = [ ] interface MergeReportEnvironmentModules { - [projectName: string]: { - environments: { - [environmentName: string]: SerializedEnvironmentModuleGraph - } - external: [id: string, externalized: string][] - } -} - -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()) { - // Vite can generate module with `file = ""` for module id "#..." - // when the actual module doesn't exist (e.g. resolve failure or mocked module) - if (mod.file == null) { - continue - } - - const importedIds: number[] = [] - for (const importedNode of mod.importedModules) { - if (importedNode.id !== null) { - 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] - // `createFileOnlyEntry('')` normalizes the file to ".". This keeps - // the graph usable, but doesn't perfectly round-trip Vite's `file = ""` - // nodes for ids like "#...". - // We may just do moduleNode.file = filePath in the future. - 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) - }) - }) + [projectName: string]: SerializedProjectModules } diff --git a/packages/vitest/src/utils/graph.ts b/packages/vitest/src/utils/module-graph/query.ts similarity index 63% rename from packages/vitest/src/utils/graph.ts rename to packages/vitest/src/utils/module-graph/query.ts index 5f8acbbe6616..274953c39cf6 100644 --- a/packages/vitest/src/utils/graph.ts +++ b/packages/vitest/src/utils/module-graph/query.ts @@ -1,38 +1,22 @@ -import type { DevEnvironment, EnvironmentModuleNode } from 'vite' -import type { Vitest } from '../node/core' -import type { ModuleGraphData } from '../types/general' -import { getTestFileEnvironment } from './environments' +import type { ModuleGraphData } from '../../types/general' +import type { + ModuleGraphQueryEnvironment, + ModuleGraphQueryNode, + ModuleGraphQueryProject, +} from './types' -export async function getModuleGraph( - ctx: Vitest, - projectName: string, +export function getTestFileModuleGraph( + project: ModuleGraphQueryProject, + environment: ModuleGraphQueryEnvironment, testFilePath: string, - viteEnvironment?: string, -): Promise { +): ModuleGraphData { const graph: Record = {} const externalized = new Set() const inlined = new Set() - - const project = ctx.getProjectByName(projectName) const browser = project.config.browser.enabled + const seen = new Map() - let environment: DevEnvironment | undefined - - if (viteEnvironment) { - environment = project.vite.environments[viteEnvironment] - } else { - environment = - project.config.experimental.viteModuleRunner === false - ? project.vite.environments.__vitest__ - : getTestFileEnvironment(project, testFilePath, browser) - } - - if (!environment) { - throw new Error(`Cannot find environment for ${testFilePath}`) - } - const seen = new Map() - - function get(mod?: EnvironmentModuleNode) { + function get(mod?: ModuleGraphQueryNode) { if (!mod || !mod.id) { return } diff --git a/packages/vitest/src/utils/module-graph/serialized.ts b/packages/vitest/src/utils/module-graph/serialized.ts new file mode 100644 index 000000000000..981f8dd1a78d --- /dev/null +++ b/packages/vitest/src/utils/module-graph/serialized.ts @@ -0,0 +1,89 @@ +import type { ModuleGraphData } from '../../types/general' +import type { + ModuleGraphQueryEnvironment, + ModuleGraphQueryNode, + ModuleGraphQueryProject, + SerializedEnvironmentModuleGraph, + SerializedProjectModules, +} from './types' +import { getTestFileModuleGraph } from './query' + +const EMPTY_MODULE_GRAPH: ModuleGraphData = { + graph: {}, + externalized: [], + inlined: [], +} + +export function getSerializedTestFileModuleGraph( + projectModules: SerializedProjectModules | undefined, + testFilePath: string, + viteEnvironment?: string, +): ModuleGraphData { + if (!projectModules) { + return EMPTY_MODULE_GRAPH + } + + let serializedGraph: SerializedEnvironmentModuleGraph | undefined + const environmentName = viteEnvironment || projectModules.project.defaultEnvironment + if (environmentName) { + serializedGraph = projectModules.environments[environmentName] + } else { + for (const environmentName in projectModules.environments) { + const environment = projectModules.environments[environmentName] + if (environment.modules.some(([idIndex]) => environment.idTable[idIndex] === testFilePath)) { + serializedGraph = environment + break + } + } + } + + if (!serializedGraph) { + return EMPTY_MODULE_GRAPH + } + + const { project, environment } = createModuleGraphShell(projectModules, serializedGraph) + return getTestFileModuleGraph(project, environment, testFilePath) +} + +// minimal project/environment shell so the static html report can run getTestFileModuleGraph +function createModuleGraphShell( + projectModules: SerializedProjectModules, + serialized: SerializedEnvironmentModuleGraph, +): { project: ModuleGraphQueryProject; environment: ModuleGraphQueryEnvironment } { + const nodes = new Map() + for (const [id, file] of serialized.modules) { + nodes.set(serialized.idTable[id], { + id: serialized.idTable[id], + file: serialized.idTable[file], + importedModules: new Set(), + }) + } + for (const [id, _file, _url, importedIds] of serialized.modules) { + const node = nodes.get(serialized.idTable[id])! + for (const importedId of importedIds) { + const importedNode = nodes.get(serialized.idTable[importedId]) + if (importedNode) { + node.importedModules.add(importedNode) + } + } + } + + const external = new Map(projectModules.external) + const { setupFiles, browserCacheDir } = projectModules.project + return { + project: { + config: { + setupFiles, + browser: { enabled: browserCacheDir != null }, + }, + browser: + browserCacheDir != null ? { vite: { config: { cacheDir: browserCacheDir } } } : undefined, + _resolver: { + wasExternalized: (id) => external.get(id) ?? false, + }, + }, + environment: { + moduleGraph: { getModuleById: (id) => nodes.get(id) }, + }, + } +} diff --git a/packages/vitest/src/utils/module-graph/types.ts b/packages/vitest/src/utils/module-graph/types.ts new file mode 100644 index 000000000000..fa64e94cf0ad --- /dev/null +++ b/packages/vitest/src/utils/module-graph/types.ts @@ -0,0 +1,47 @@ +export interface SerializedProjectModules { + environments: { + [environmentName: string]: SerializedEnvironmentModuleGraph + } + external: [id: string, externalized: string][] + project: { + setupFiles: string[] + defaultEnvironment?: string + browserCacheDir?: string + } +} + +export interface SerializedEnvironmentModuleGraph { + idTable: string[] + modules: SerializedEnvironmentModuleNode[] +} + +export type SerializedEnvironmentModuleNode = [ + id: number, + file: number, + url: number, + importedIds: number[], +] + +// What `getTestFileModuleGraph` reads, satisfied by live Vite objects and by the serialized shell. + +// subset of `TestProject` +export interface ModuleGraphQueryProject { + config: { + setupFiles: string[] + browser: { enabled: boolean } + } + browser?: { vite: { config: { cacheDir: string } } } + _resolver: { wasExternalized: (id: string) => string | false } +} + +// subset of `DevEnvironment` +export interface ModuleGraphQueryEnvironment { + moduleGraph: { getModuleById: (id: string) => ModuleGraphQueryNode | undefined } +} + +// subset of `EnvironmentModuleNode` +export interface ModuleGraphQueryNode { + id: string | null + file: string | null + importedModules: Set +} diff --git a/test/e2e/test/reporters/merge-reports.test.ts b/test/e2e/test/reporters/merge-reports.test.ts index 79d38f4039ef..8c8552756f02 100644 --- a/test/e2e/test/reporters/merge-reports.test.ts +++ b/test/e2e/test/reporters/merge-reports.test.ts @@ -1,17 +1,20 @@ import type { RunnerTestFile as File, RunnerTestCase as Test } from 'vitest' import type { TestUserConfig, Vitest } from 'vitest/node' import type { RunVitestConfig } from '#test-utils' +import type { HTMLReportMetadata } from '../../../../packages/ui/client/composables/client/static.js' import type { MergeReport } from '../../../../packages/vitest/src/node/reporters/blob.js' import { cpSync, existsSync, readdirSync, readFileSync, rmSync } from 'node:fs' import { mkdir, writeFile } from 'node:fs/promises' import path from 'node:path' +import { gunzipSync } from 'node:zlib' import { playwright } from '@vitest/browser-playwright' -import { stringify } from 'flatted' +import { parse, stringify } from 'flatted' import { dirname, resolve } from 'pathe' import { beforeEach, expect, test, TestRunner } from 'vitest' import { version } from 'vitest/package.json' import { buildTestTree, runVitest, useFS, useTmpFS } from '#test-utils' -import { getModuleGraph } from '../../../../packages/vitest/src/utils/graph.js' +import { getModuleGraph } from '../../../../packages/vitest/src/node/module-graph.js' +import { getSerializedTestFileModuleGraph } from '../../../../packages/vitest/src/utils/module-graph/serialized.js' // always relative to CWD because it's used only from the CLI, // so we need to correctly resolve it here @@ -500,13 +503,13 @@ test.for(['node', 'browser'])('module graph and html reporter $0', async (mode) You can run npx vite preview --outDir .vitest to see the test results. " `) + expect.assert(result3.ctx) + const htmlModuleGraphJson = getHtmlReportModuleGraph(result3.ctx) + expect(htmlModuleGraphJson).toBe(generatedModuleGraphJson) }) async function getSerializedModuleGraph(ctx: Vitest) { - const files = ctx.state - .getFiles() - .slice() - .sort((a, b) => a.filepath.localeCompare(b.filepath)) + const files = getSortedFiles(ctx) const moduleGraphs = Object.fromEntries( await Promise.all( files.map(async (file) => { @@ -516,6 +519,35 @@ async function getSerializedModuleGraph(ctx: Vitest) { }), ), ) + return normalizeModuleGraphJson(ctx, moduleGraphs) +} + +function getHtmlReportModuleGraph(ctx: Vitest) { + const metadata: HTMLReportMetadata = parse( + gunzipSync(readFileSync(resolve(ctx.config.root, '.vitest/ui/html.meta.json.gz'))).toString(), + ) + const moduleGraphs = Object.fromEntries( + getSortedFiles(ctx).map((file) => { + const projectModules = metadata.environmentModules[file.projectName || ''] + const graph = getSerializedTestFileModuleGraph( + projectModules, + file.filepath, + file.viteEnvironment, + ) + return [file.filepath, graph] as const + }), + ) + return normalizeModuleGraphJson(ctx, moduleGraphs) +} + +function getSortedFiles(ctx: Vitest) { + return ctx.state + .getFiles() + .slice() + .sort((a, b) => a.filepath.localeCompare(b.filepath)) +} + +function normalizeModuleGraphJson(ctx: Vitest, moduleGraphs: object) { return JSON.stringify(moduleGraphs, null, 2) .replaceAll(ctx.config.root, '') .replace(/"[^"\n]*\/node_modules\//g, '"/')