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',