From 123be342c7f9f8238c993045c4fc0a69952d999c Mon Sep 17 00:00:00 2001 From: Charles Lyding <19598772+clydin@users.noreply.github.com> Date: Wed, 4 Feb 2026 12:54:59 -0500 Subject: [PATCH] fix(@angular/build): bundle setup files in unit-test builder for Vitest This change updates the Vitest runner in the unit-test builder to bundle 'setupFiles' through the application build pipeline, similar to test files. This ensures that setup files are processed with the same transformations and environment as the application code. Note: Only setup files specified in the 'setupFiles' builder option are bundled. Setup files referenced solely within a custom Vitest configuration file (via 'runnerConfig') will not be bundled by the Angular CLI and will be processed directly by Vitest. --- .../unit-test/runners/vitest/build-options.ts | 14 ++++ .../unit-test/runners/vitest/executor.ts | 14 +++- .../src/builders/unit-test/test-discovery.ts | 26 ++++++- .../builders/unit-test/test-discovery_spec.ts | 74 ++++++++++++++++++- .../tests/options/setup-files_spec.ts | 19 ++--- tests/e2e/tests/vitest/browser-playwright.ts | 5 ++ tests/e2e/tests/vitest/browser-webdriverio.ts | 5 ++ 7 files changed, 139 insertions(+), 18 deletions(-) diff --git a/packages/angular/build/src/builders/unit-test/runners/vitest/build-options.ts b/packages/angular/build/src/builders/unit-test/runners/vitest/build-options.ts index 7129ea4fff54..756037b1e390 100644 --- a/packages/angular/build/src/builders/unit-test/runners/vitest/build-options.ts +++ b/packages/angular/build/src/builders/unit-test/runners/vitest/build-options.ts @@ -98,6 +98,20 @@ export async function getVitestBuildOptions( workspaceRoot, removeTestExtension: true, }); + + if (options.setupFiles?.length) { + const setupEntryPoints = getTestEntrypoints(options.setupFiles, { + projectSourceRoot, + workspaceRoot, + removeTestExtension: false, + prefix: 'setup', + }); + + for (const [entryPoint, setupFile] of setupEntryPoints) { + entryPoints.set(entryPoint, setupFile); + } + } + entryPoints.set('init-testbed', 'angular:test-bed-init'); // The 'vitest' package is always external for testing purposes diff --git a/packages/angular/build/src/builders/unit-test/runners/vitest/executor.ts b/packages/angular/build/src/builders/unit-test/runners/vitest/executor.ts index 39584a5844f0..19d86e4c22dc 100644 --- a/packages/angular/build/src/builders/unit-test/runners/vitest/executor.ts +++ b/packages/angular/build/src/builders/unit-test/runners/vitest/executor.ts @@ -240,6 +240,15 @@ export class VitestExecutor implements TestExecutor { project = `${projectName} (${browserOptions.browser.instances[0].browser})`; } + // Filter internal entries and setup files from the include list + const internalEntries = ['angular:']; + const setupFileSet = new Set(testSetupFiles); + const include = [...this.testFileToEntryPoint.keys()].filter((entry) => { + return ( + !internalEntries.some((internal) => entry.startsWith(internal)) && !setupFileSet.has(entry) + ); + }); + return startVitest( 'test', undefined, @@ -272,10 +281,7 @@ export class VitestExecutor implements TestExecutor { reporters, setupFiles: testSetupFiles, projectPlugins, - include: [...this.testFileToEntryPoint.keys()].filter( - // Filter internal entries - (entry) => !entry.startsWith('angular:'), - ), + include, }), ], }, diff --git a/packages/angular/build/src/builders/unit-test/test-discovery.ts b/packages/angular/build/src/builders/unit-test/test-discovery.ts index 64e9718e48ac..7bad7079dc90 100644 --- a/packages/angular/build/src/builders/unit-test/test-discovery.ts +++ b/packages/angular/build/src/builders/unit-test/test-discovery.ts @@ -81,6 +81,7 @@ interface TestEntrypointsOptions { projectSourceRoot: string; workspaceRoot: string; removeTestExtension?: boolean; + prefix?: string; } /** @@ -93,22 +94,39 @@ interface TestEntrypointsOptions { */ export function getTestEntrypoints( testFiles: string[], - { projectSourceRoot, workspaceRoot, removeTestExtension }: TestEntrypointsOptions, + { + projectSourceRoot, + workspaceRoot, + removeTestExtension, + prefix = 'spec', + }: TestEntrypointsOptions, ): Map { const seen = new Set(); + const counters = new Map(); const roots = [projectSourceRoot, workspaceRoot]; + const infixes = TEST_FILE_INFIXES.map((i) => i.slice(1)); + if (!infixes.includes(prefix)) { + infixes.push(prefix); + } + const infixesPattern = infixes.join('|'); return new Map( Array.from(testFiles, (testFile) => { const fileName = generateNameFromPath(testFile, roots, !!removeTestExtension); - const baseName = `spec-${fileName}`; + const baseName = fileName === prefix ? prefix : `${prefix}-${fileName}`; let uniqueName = baseName; - let suffix = 2; + // Start at 2 for collisions as the first instance remains suffix-less. + let suffix = counters.get(baseName) ?? 2; + while (seen.has(uniqueName)) { - uniqueName = `${baseName}-${suffix}`.replace(/([^\w](?:spec|test))-([\d]+)$/, '-$2$1'); + uniqueName = `${baseName}-${suffix}`.replace( + new RegExp(`([^\\w](?:${infixesPattern}))-([\\d]+)$`), + '-$2$1', + ); ++suffix; } seen.add(uniqueName); + counters.set(baseName, suffix); return [uniqueName, testFile]; }), diff --git a/packages/angular/build/src/builders/unit-test/test-discovery_spec.ts b/packages/angular/build/src/builders/unit-test/test-discovery_spec.ts index 617839868d50..dcee1718a976 100644 --- a/packages/angular/build/src/builders/unit-test/test-discovery_spec.ts +++ b/packages/angular/build/src/builders/unit-test/test-discovery_spec.ts @@ -6,7 +6,79 @@ * found in the LICENSE file at https://angular.dev/license */ -import { generateNameFromPath } from './test-discovery'; +import { generateNameFromPath, getTestEntrypoints } from './test-discovery'; + +describe('getTestEntrypoints', () => { + const workspaceRoot = '/project'; + const projectSourceRoot = '/project/src'; + const options = { workspaceRoot, projectSourceRoot }; + + it('should generate entry points for unique files', () => { + const files = ['/project/src/a.spec.ts', '/project/src/b.spec.ts']; + const result = getTestEntrypoints(files, { ...options, removeTestExtension: true }); + + expect(result.size).toBe(2); + expect(result.get('spec-a')).toBe(files[0]); + expect(result.get('spec-b')).toBe(files[1]); + }); + + it('should handle collisions with numeric suffixes correctly positioned', () => { + // To trigger the regex replacement, the name must end in 'spec', 'test', or the prefix. + const files = ['/project/src/sub-test.spec.ts', '/project/src/sub/test.spec.ts']; + + const result = getTestEntrypoints(files, { ...options, removeTestExtension: true }); + // Both map to 'sub-test' (relative to src root). + // baseName = 'spec-sub-test'. + // 1st: 'spec-sub-test'. + // 2nd: 'spec-sub-test-2'. Regex matches '-test-2'. -> 'spec-sub-2-test'. + expect(result.get('spec-sub-test')).toBe(files[0]); + expect(result.get('spec-sub-2-test')).toBe(files[1]); + }); + + it('should handle setup file naming with prefix setup', () => { + const files = ['/project/src/setup.ts']; + const result = getTestEntrypoints(files, { + ...options, + removeTestExtension: false, + prefix: 'setup', + }); + + // 'setup.ts' -> 'setup' (via generateNameFromPath). + // prefix='setup'. + // baseName = 'setup' === 'setup' ? 'setup' : 'setup-setup' -> 'setup'. + expect(result.get('setup')).toBe(files[0]); + }); + + it('should handle setup file collisions', () => { + const files = ['/project/src/sub-setup.ts', '/project/src/sub/setup.ts']; + const result = getTestEntrypoints(files, { + ...options, + removeTestExtension: false, + prefix: 'setup', + }); + + // Both map to 'sub-setup' (baseName: 'setup-sub-setup'). + // 1st: 'setup-sub-setup'. + // 2nd: 'setup-sub-setup-2'. Regex matches '-setup-2'. -> 'setup-sub-2-setup'. + expect(result.get('setup-sub-setup')).toBe(files[0]); + expect(result.get('setup-sub-2-setup')).toBe(files[1]); + }); + + it('should handle custom prefixes', () => { + const files = ['/project/src/sub-my-file.ts', '/project/src/sub/my-file.ts']; + const result = getTestEntrypoints(files, { + ...options, + removeTestExtension: false, + prefix: 'custom', + }); + + // 'sub-my-file.ts' -> 'sub-my-file'. + // baseName: 'custom-sub-my-file'. + expect(result.get('custom-sub-my-file')).toBe(files[0]); + // 'custom-sub-my-file-2'. Does not match regex (no 'spec'/'test'/'custom' at end). + expect(result.get('custom-sub-my-file-2')).toBe(files[1]); + }); +}); describe('generateNameFromPath', () => { const roots = ['/project/src/', '/project/']; diff --git a/packages/angular/build/src/builders/unit-test/tests/options/setup-files_spec.ts b/packages/angular/build/src/builders/unit-test/tests/options/setup-files_spec.ts index 5f888ed7ff64..be70c833c9a9 100644 --- a/packages/angular/build/src/builders/unit-test/tests/options/setup-files_spec.ts +++ b/packages/angular/build/src/builders/unit-test/tests/options/setup-files_spec.ts @@ -16,7 +16,7 @@ import { } from '../setup'; describeBuilder(execute, UNIT_TEST_BUILDER_INFO, (harness) => { - xdescribe('Option: "setupFiles"', () => { + describe('Option: "setupFiles"', () => { beforeEach(async () => { setupApplicationTarget(harness); }); @@ -27,19 +27,21 @@ describeBuilder(execute, UNIT_TEST_BUILDER_INFO, (harness) => { setupFiles: ['src/setup.ts'], }); - const { result, error } = await harness.executeOnce({ outputLogsOnFailure: false }); - expect(result).toBeUndefined(); - expect(error?.message).toMatch(`The specified setup file "src/setup.ts" does not exist.`); + const { result, logs } = await harness.executeOnce({ outputLogsOnFailure: false }); + expect(result?.success).toBeFalse(); + // Verify that the build failed due to resolution error (esbuild error) + expectLog(logs, /Could not resolve/); + expectLog(logs, /src\/setup\.ts/); }); it('should include the setup files', async () => { await harness.writeFiles({ - 'src/setup.ts': `console.log('Hello from setup.ts');`, + 'src/setup.ts': `globalThis['TEST_SETUP_RAN'] = true;`, 'src/app/app.component.spec.ts': ` import { describe, expect, test } from 'vitest' describe('AppComponent', () => { - test('should create the app', () => { - expect(true).toBe(true); + test('should have run setup file', () => { + expect(globalThis['TEST_SETUP_RAN']).toBe(true); }); });`, }); @@ -49,9 +51,8 @@ describeBuilder(execute, UNIT_TEST_BUILDER_INFO, (harness) => { setupFiles: ['src/setup.ts'], }); - const { result, logs } = await harness.executeOnce(); + const { result } = await harness.executeOnce(); expect(result?.success).toBeTrue(); - expectLog(logs, 'Hello from setup.ts'); }); }); }); diff --git a/tests/e2e/tests/vitest/browser-playwright.ts b/tests/e2e/tests/vitest/browser-playwright.ts index fa9ec43aabf3..fc8e7e5b53b3 100644 --- a/tests/e2e/tests/vitest/browser-playwright.ts +++ b/tests/e2e/tests/vitest/browser-playwright.ts @@ -3,6 +3,7 @@ import { applyVitestBuilder } from '../../utils/vitest'; import { ng } from '../../utils/process'; import { installPackage } from '../../utils/packages'; import { writeFile } from '../../utils/fs'; +import { updateJsonFile } from '../../utils/project'; export default async function (): Promise { await applyVitestBuilder(); @@ -19,6 +20,10 @@ export default async function (): Promise { `, ); + await updateJsonFile('tsconfig.spec.json', (json) => { + json.include = [...(json.include || []), 'src/setup1.ts']; + }); + const { stdout } = await ng( 'test', '--no-watch', diff --git a/tests/e2e/tests/vitest/browser-webdriverio.ts b/tests/e2e/tests/vitest/browser-webdriverio.ts index 4ea1b913c3b0..0838761eb2ad 100644 --- a/tests/e2e/tests/vitest/browser-webdriverio.ts +++ b/tests/e2e/tests/vitest/browser-webdriverio.ts @@ -3,6 +3,7 @@ import { applyVitestBuilder } from '../../utils/vitest'; import { ng } from '../../utils/process'; import { installPackage } from '../../utils/packages'; import { writeFile } from '../../utils/fs'; +import { updateJsonFile } from '../../utils/project'; export default async function (): Promise { await applyVitestBuilder(); @@ -20,6 +21,10 @@ export default async function (): Promise { `, ); + await updateJsonFile('tsconfig.spec.json', (json) => { + json.include = [...(json.include || []), 'src/setup1.ts']; + }); + const { stdout } = await ng( 'test', '--no-watch',