diff --git a/test/unit/test/module-diagnostic.test.ts b/test/unit/test/module-diagnostic.test.ts index 638ef614a..a331f8124 100644 --- a/test/unit/test/module-diagnostic.test.ts +++ b/test/unit/test/module-diagnostic.test.ts @@ -1,26 +1,424 @@ -import type { RunnerTestFile } from 'vitest' import type { TestModule } from 'vitest/node' -import { expect, test } from 'vitest' -import { runInlineTests } from '../../test-utils' +import MagicString from 'magic-string' +import { describe, expect, test } from 'vitest' +import { replaceRoot, runInlineTests } from '../../test-utils' -// TODO: write comprehensive tests -test.skip('123', async () => { - const source = ` -import {} from './hello-world' -import { test } from 'vitest' -// import 'side-effect' -// import * as m from 'module-import' -// import * as m2 from "module-import-2" -test('hello world') - ` - const { fs, ctx } = await runInlineTests({ +const importDurationsConfig = { + experimental: { + importDurations: { + limit: 100, + }, + }, +} + +async function getSourceModuleDiagnostic( + source: string, + dependencies: Record, + targetModuleFile = './source.test.js', +) { + const { fs, root, ctx, stderr } = await runInlineTests({ 'source.test.js': source, - 'hello-world': '', + ...dependencies, + }, importDurationsConfig) + + expect(stderr).toBe('') + expect(ctx).toBeDefined() + + const testFileId = fs.resolveFile('./source.test.js') + const testFile = ctx!.state.filesMap.get(testFileId) + if (!testFile?.length) { + throw new Error(`Test file was not collected: ${testFileId}`) + } + + // Use the executed test's Vite environment while diagnosing the requested module. + const testModule = ctx!.state.getReportedEntity(testFile[0]) as TestModule + const moduleId = fs.resolveFile(targetModuleFile) + const diagnostic = await ctx!.experimental_getSourceModuleDiagnostic(moduleId, testModule) + const localModules = diagnostic.modules.filter(module => module.rawUrl.startsWith('./')) + + return { + diagnostic, + localModules, + root, + } +} + +function normalizeModules( + modules: Awaited>['diagnostic']['modules'], + root: string, +) { + return modules.map(({ selfTime, totalTime, transformTime, ...module }) => ({ + ...module, + resolvedId: replaceRoot(module.resolvedId, root), + importer: module.importer ? replaceRoot(module.importer, root) : undefined, + timingTypes: { + selfTime: typeof selfTime, + totalTime: typeof totalTime, + transformTime: typeof transformTime, + }, + })) +} + +function normalizeUntrackedModules( + modules: Awaited>['diagnostic']['untrackedModules'], + root: string, +) { + return [...modules] + .sort((a, b) => a.resolvedUrl.localeCompare(b.resolvedUrl)) + .map(({ selfTime, totalTime, transformTime, ...module }) => ({ + ...module, + resolvedId: replaceRoot(module.resolvedId, root), + importer: module.importer ? replaceRoot(module.importer, root) : undefined, + timingTypes: { + selfTime: typeof selfTime, + totalTime: typeof totalTime, + transformTime: typeof transformTime, + }, + })) +} + +describe('experimental_getSourceModuleDiagnostic', () => { + test('Static imports and re-exports should receive source locations and duration information', async ({ skip, task }) => { + skip(task.file.pool !== 'threads', 'run only once inside threads') + + const source = ` +import { expect, test } from 'vitest' +import { value } from './dependency.js' +export { reexported } from './reexported.js' + +test('uses an imported value', () => { + expect(value).toBe(42) +}) +` + const { diagnostic, root, localModules } = await getSourceModuleDiagnostic(source, { + 'dependency.js': 'export const value = 42', + 'reexported.js': 'export const reexported = true', + }) + + expect(diagnostic.untrackedModules).toEqual([]) + expect(localModules).toHaveLength(2) + expect(localModules.map(module => source.slice(module.startIndex, module.endIndex))).toEqual([ + '\'./dependency.js\'', + '\'./reexported.js\'', + ]) + expect(normalizeModules(localModules, root)).toMatchInlineSnapshot(` + [ + { + "end": { + "column": 39, + "line": 3, + }, + "endIndex": 78, + "external": undefined, + "importer": "/source.test.js", + "rawUrl": "./dependency.js", + "resolvedId": "/dependency.js", + "resolvedUrl": "/dependency.js", + "start": { + "column": 22, + "line": 3, + }, + "startIndex": 61, + "timingTypes": { + "selfTime": "number", + "totalTime": "number", + "transformTime": "number", + }, + }, + { + "end": { + "column": 44, + "line": 4, + }, + "endIndex": 123, + "external": undefined, + "importer": "/source.test.js", + "rawUrl": "./reexported.js", + "resolvedId": "/reexported.js", + "resolvedUrl": "/reexported.js", + "start": { + "column": 27, + "line": 4, + }, + "startIndex": 106, + "timingTypes": { + "selfTime": "number", + "totalTime": "number", + "transformTime": "number", + }, + }, + ] + `) + }) + + test('Importing the same resolved module twice should not count its loading cost twice.', async ({ skip, task }) => { + skip(task.file.pool !== 'threads', 'run only once inside threads') + + const source = ` +import { expect, test } from 'vitest' +import { value } from './dependency.js' +import { value as duplicate } from './dependency.js' + +test('uses both imports', () => { + expect(value + duplicate).toBe(84) +}) +` + const { diagnostic, root } = await getSourceModuleDiagnostic(source, { + 'dependency.js': 'export const value = 42', + }) + + const dependencyModules = diagnostic.modules.filter(module => module.rawUrl === './dependency.js') + const [firstImport, secondImport] = dependencyModules + + expect(dependencyModules).toHaveLength(2) + expect(firstImport.resolvedId).toBe(secondImport.resolvedId) + expect(firstImport.start).not.toEqual(secondImport.start) + expect(secondImport).toMatchObject({ + selfTime: 0, + totalTime: 0, + transformTime: 0, + }) + expect(normalizeModules(dependencyModules, root)).toMatchInlineSnapshot(` + [ + { + "end": { + "column": 39, + "line": 3, + }, + "endIndex": 78, + "external": undefined, + "importer": "/source.test.js", + "rawUrl": "./dependency.js", + "resolvedId": "/dependency.js", + "resolvedUrl": "/dependency.js", + "start": { + "column": 22, + "line": 3, + }, + "startIndex": 61, + "timingTypes": { + "selfTime": "number", + "totalTime": "number", + "transformTime": "number", + }, + }, + { + "end": { + "column": 52, + "line": 4, + }, + "endIndex": 131, + "external": undefined, + "importer": "/source.test.js", + "rawUrl": "./dependency.js", + "resolvedId": "/dependency.js", + "resolvedUrl": "/dependency.js", + "start": { + "column": 35, + "line": 4, + }, + "startIndex": 114, + "timingTypes": { + "selfTime": "number", + "totalTime": "number", + "transformTime": "number", + }, + }, + ] + `) + }) + + test('non transformed modules should return an empty diagnostic', async ({ skip, task }) => { + skip(task.file.pool !== 'threads', 'run only once inside threads') + + const source = ` +import { expect, test } from 'vitest' +import { value } from './used.js' + +test('uses an imported value', () => { + expect(value).toBe(42) +}) +` + const { diagnostic } = await getSourceModuleDiagnostic(source, { + 'used.js': 'export const value = 42', + 'unused.js': 'export const used = false', + }, './unused.js') + + expect(diagnostic).toEqual({ + modules: [], + untrackedModules: [], + }) }) - const file = fs.resolveFile('./source.test.js') - const testFile = ctx!.state.filesMap.get(file) as RunnerTestFile[] | undefined - const testModule = testFile?.length ? ctx!.state.getReportedEntity(testFile[0]) as TestModule : undefined - const diagnostic = await ctx!.experimental_getSourceModuleDiagnostic(file, testModule) - expect(diagnostic).toBeDefined() + test('should aggregate durations from all test modules when no testModule is provided', async ({ skip, task }) => { + skip(task.file.pool !== 'threads', 'run only once inside threads') + + const sharedSource = ` +import { value } from './dependency.js' +export const shared = value +` + const { fs, root, ctx, stderr } = await runInlineTests({ + 'shared.js': sharedSource, + 'dependency.js': 'export const value = 42', + 'first.test.js': ` +import { expect, test } from 'vitest' +import { shared } from './shared.js' +test('first test', () => expect(shared).toBe(42)) +`, + 'second.test.js': ` +import { expect, test } from 'vitest' +import { shared } from './shared.js' +test('second test', () => expect(shared).toBe(42)) +`, + }, importDurationsConfig) + + expect(stderr).toBe('') + expect(ctx).toBeDefined() + + const getTestModule = (filename: string) => { + const file = fs.resolveFile(filename) + const testFiles = ctx!.state.filesMap.get(file) + if (!testFiles?.length) { + throw new Error(`Test file was not collected: ${file}`) + } + return ctx!.state.getReportedEntity(testFiles[0]) as TestModule + } + + const sharedId = fs.resolveFile('./shared.js') + const firstDiagnostic = await ctx!.experimental_getSourceModuleDiagnostic( + sharedId, + getTestModule('./first.test.js'), + ) + const secondDiagnostic = await ctx!.experimental_getSourceModuleDiagnostic( + sharedId, + getTestModule('./second.test.js'), + ) + const aggregatedDiagnostic = await ctx!.experimental_getSourceModuleDiagnostic(sharedId) + + const firstDependency = firstDiagnostic.modules.find(module => module.rawUrl === './dependency.js') + const secondDependency = secondDiagnostic.modules.find(module => module.rawUrl === './dependency.js') + const aggregatedDependency = aggregatedDiagnostic.modules.find(module => module.rawUrl === './dependency.js') + + if (!firstDependency || !secondDependency || !aggregatedDependency) { + throw new Error('Expected dependency diagnostic was not collected') + } + + expect(aggregatedDependency.selfTime).toBe(firstDependency.selfTime + secondDependency.selfTime) + expect(aggregatedDependency.totalTime).toBe(firstDependency.totalTime + secondDependency.totalTime) + expect(sharedSource.slice(aggregatedDependency.startIndex, aggregatedDependency.endIndex)).toBe( + '\'./dependency.js\'', + ) + expect(normalizeModules([aggregatedDependency], root)).toMatchInlineSnapshot(` + [ + { + "end": { + "column": 39, + "line": 2, + }, + "endIndex": 40, + "external": undefined, + "importer": "/shared.js", + "rawUrl": "./dependency.js", + "resolvedId": "/dependency.js", + "resolvedUrl": "/dependency.js", + "start": { + "column": 22, + "line": 2, + }, + "startIndex": 23, + "timingTypes": { + "selfTime": "number", + "totalTime": "number", + "transformTime": "number", + }, + }, + ] + `) + }) + + test('should report imports that are injected during transformation as untracked', async ({ skip, task }) => { + skip(task.file.pool !== 'threads', 'run only once inside threads') + + const originalImport = 'import \'./original.js\'' + const source = ` +${originalImport} +import { test } from 'vitest' +test('loads injected modules', () => {}) +` + const { fs, root, ctx, stderr } = await runInlineTests({ + 'source.test.js': source, + 'original.js': '', + 'injected-first.js': '', + }, { + ...importDurationsConfig, + $viteConfig: { + plugins: [ + { + name: 'inject-module-imports', + enforce: 'pre', + transform(code, id) { + if (!id.endsWith('/source.test.js')) { + return + } + + const importIndex = code.indexOf(originalImport) + if (importIndex === -1) { + throw new Error(`Expected source.test.js to contain ${originalImport}`) + } + const transformed = new MagicString(code) + const injectedImports = [ + 'import \'./injected-first.js\'', + originalImport, + ].join('\n') + transformed.overwrite(importIndex, importIndex + originalImport.length, injectedImports) + const map = transformed.generateMap({ + hires: true, + includeContent: true, + source: id, + }) + + return { + code: transformed.toString(), + map, + } + }, + }, + ], + }, + }) + + expect(stderr).toBe('') + expect(ctx).toBeDefined() + + const file = fs.resolveFile('./source.test.js') + const testFiles = ctx!.state.filesMap.get(file) + if (!testFiles?.length) { + throw new Error(`Test file was not collected: ${file}`) + } + + const testModule = ctx!.state.getReportedEntity(testFiles[0]) as TestModule + const diagnostic = await ctx!.experimental_getSourceModuleDiagnostic(file, testModule) + const originalModule = diagnostic.modules.find(module => module.rawUrl === './original.js') + + expect(originalModule).toBeDefined() + expect(replaceRoot(originalModule!.resolvedId, root)).toBe('/original.js') + expect(source.slice(originalModule!.startIndex, originalModule!.endIndex)).toBe('\'./original.js\'') + expect(diagnostic.untrackedModules).toHaveLength(1) + expect(normalizeUntrackedModules(diagnostic.untrackedModules, root)).toMatchInlineSnapshot(` + [ + { + "external": undefined, + "importer": "/source.test.js", + "resolvedId": "/injected-first.js", + "resolvedUrl": "/injected-first.js", + "timingTypes": { + "selfTime": "number", + "totalTime": "number", + "transformTime": "number", + }, + "url": "./original.js", + }, + ] + `) + }) })