diff --git a/packages/angular/build/src/builders/unit-test/runners/vitest/plugins.ts b/packages/angular/build/src/builders/unit-test/runners/vitest/plugins.ts index 39d42c62d05d..6e621ecc588b 100644 --- a/packages/angular/build/src/builders/unit-test/runners/vitest/plugins.ts +++ b/packages/angular/build/src/builders/unit-test/runners/vitest/plugins.ts @@ -14,6 +14,7 @@ import path from 'node:path'; import type { BrowserConfigOptions, InlineConfig, + ResolvedConfig, UserWorkspaceConfig, VitestPlugin, } from 'vitest/node'; @@ -112,8 +113,11 @@ export async function createVitestConfigPlugin( delete config.plugins; } - // Add browser source map support - if (browser || testConfig?.browser?.enabled) { + // Add browser source map support if coverage is enabled + if ( + (browser || testConfig?.browser?.enabled) && + (options.coverage.enabled || testConfig?.coverage?.enabled) + ) { projectPlugins.unshift(createSourcemapSupportPlugin()); setupFiles.unshift('virtual:source-map-support'); } @@ -185,11 +189,15 @@ async function loadResultFile(file: ResultFile): Promise { export function createVitestPlugins(pluginOptions: PluginOptions): VitestPlugins { const { workspaceRoot, buildResultFiles, testFileToEntryPoint } = pluginOptions; const isWindows = platform() === 'win32'; + let vitestConfig: ResolvedConfig; return [ { name: 'angular:test-in-memory-provider', enforce: 'pre', + configureVitest(context) { + vitestConfig = context.vitest.config; + }, resolveId: (id, importer) => { // Fast path for test entry points. if (testFileToEntryPoint.has(id)) { @@ -248,7 +256,7 @@ export function createVitestPlugins(pluginOptions: PluginOptions): VitestPlugins // If the module cannot be resolved from the build artifacts, let other plugins handle it. return undefined; }, - load: async (id) => { + async load(id) { assert(buildResultFiles.size > 0, 'buildResult must be available for in-memory loading.'); // Attempt to load as a source test file. @@ -257,11 +265,14 @@ export function createVitestPlugins(pluginOptions: PluginOptions): VitestPlugins if (entryPoint) { outputPath = entryPoint + '.js'; - // To support coverage exclusion of the actual test file, the virtual - // test entry point only references the built and bundled intermediate file. - return { - code: `import "./${outputPath}";`, - }; + if (vitestConfig.coverage.enabled) { + // To support coverage exclusion of the actual test file, the virtual + // test entry point only references the built and bundled intermediate file. + // If vitest supported an "excludeOnlyAfterRemap" option, this could be removed completely. + return { + code: `import "./${outputPath}";`, + }; + } } else { // Attempt to load as a built artifact. const relativePath = path.relative(workspaceRoot, id); @@ -283,6 +294,17 @@ export function createVitestPlugins(pluginOptions: PluginOptions): VitestPlugins if (map) { if (!map.sources?.length && !map.sourcesContent?.length && !map.mappings) { map.sources = ['virtual:builder']; + } else if (!vitestConfig.coverage.enabled && Array.isArray(map.sources)) { + map.sources = (map.sources as string[]).map((source) => { + if (source.startsWith('angular:')) { + return source; + } + + // source is relative to the workspace root because the output file is at the root of the output. + const absoluteSource = path.join(workspaceRoot, source); + + return toPosixPath(path.relative(path.dirname(id), absoluteSource)); + }); } } diff --git a/tests/e2e/tests/vitest/node-sourcemaps.ts b/tests/e2e/tests/vitest/node-sourcemaps.ts new file mode 100644 index 000000000000..8e317b2b72cf --- /dev/null +++ b/tests/e2e/tests/vitest/node-sourcemaps.ts @@ -0,0 +1,37 @@ +import assert from 'node:assert/strict'; +import { applyVitestBuilder } from '../../utils/vitest'; +import { ng, noSilentNg } from '../../utils/process'; +import { writeFile } from '../../utils/fs'; +import { stripVTControlCharacters } from 'node:util'; + +export default async function (): Promise { + await applyVitestBuilder(); + await ng('generate', 'component', 'my-comp'); + + // Add a failing test to verify source map support + await writeFile( + 'src/app/failing.spec.ts', + ` + describe('Failing Test', () => { + it('should fail', () => { + expect(true).toBe(false); + }); + }); + `, + ); + + try { + await noSilentNg('test', '--no-watch'); + throw new Error('Expected "ng test" to fail.'); + } catch (error: any) { + const stdout = stripVTControlCharacters(error.stdout || error.message); + // We expect the failure from failing.spec.ts + assert.match(stdout, /1 failed/, 'Expected 1 test to fail.'); + // Check that the stack trace points to the correct file + assert.match( + stdout, + /\bsrc[\/\\]app[\/\\]failing\.spec\.ts:4:\d+/, + 'Expected stack trace to point to the source file.', + ); + } +}