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 05aba1d9c261..d60d1dd162aa 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 @@ -11,7 +11,7 @@ import { toPosixPath } from '../../../../utils/path'; import type { ApplicationBuilderInternalOptions } from '../../../application/options'; import { OutputHashing } from '../../../application/schema'; import { NormalizedUnitTestBuilderOptions, injectTestingPolyfills } from '../../options'; -import { findTests, getTestEntrypoints } from '../../test-discovery'; +import { findTests, getSetupEntrypoints, getTestEntrypoints } from '../../test-discovery'; import { RunnerOptions } from '../api'; function createTestBedInitVirtualFile( @@ -88,12 +88,19 @@ export async function getVitestBuildOptions( ); } - const entryPoints = getTestEntrypoints(testFiles, { + const testEntryPoints = getTestEntrypoints(testFiles, { projectSourceRoot, workspaceRoot, removeTestExtension: true, }); - entryPoints.set('init-testbed', 'angular:test-bed-init'); + const setupEntryPoints = getSetupEntrypoints(options.setupFiles, { + projectSourceRoot, + workspaceRoot, + removeTestExtension: true, + }); + setupEntryPoints.set('init-testbed', 'angular:test-bed-init'); + + const entryPoints = new Map([...testEntryPoints, ...setupEntryPoints]); // The 'vitest' package is always external for testing purposes const externalDependencies = ['vitest']; @@ -138,6 +145,6 @@ export async function getVitestBuildOptions( virtualFiles: { 'angular:test-bed-init': testBedInitContents, }, - testEntryPointMappings: entryPoints, + testEntryPointMappings: testEntryPoints, }; } 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 8d09550b671a..e61e488dd744 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 @@ -26,6 +26,7 @@ import type { TestExecutor } from '../api'; import { setupBrowserConfiguration } from './browser-provider'; import { findVitestBaseConfig } from './configuration'; import { createVitestConfigPlugin, createVitestPlugins } from './plugins'; +import { getSetupEntrypoints } from '../../test-discovery'; export class VitestExecutor implements TestExecutor { private vitest: Vitest | undefined; @@ -127,9 +128,19 @@ export class VitestExecutor implements TestExecutor { } private prepareSetupFiles(): string[] { - const { setupFiles } = this.options; + const { setupFiles, workspaceRoot } = this.options; + + const setupFilesEntrypoints = getSetupEntrypoints(setupFiles, { + projectSourceRoot: this.options.projectSourceRoot, + workspaceRoot, + removeTestExtension: true, + }); + const setupFileNames = Array.from(setupFilesEntrypoints.keys()).map( + (entrypoint) => `${entrypoint}.js`, + ); + // Add setup file entries for TestBed initialization and project polyfills - const testSetupFiles = ['init-testbed.js', ...setupFiles]; + const testSetupFiles = ['init-testbed.js', ...setupFileNames]; // TODO: Provide additional result metadata to avoid needing to extract based on filename if (this.buildResultFiles.has('polyfills.js')) { 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 89d38dbbe787..5cb56e95cdf5 100644 --- a/packages/angular/build/src/builders/unit-test/test-discovery.ts +++ b/packages/angular/build/src/builders/unit-test/test-discovery.ts @@ -73,7 +73,7 @@ export async function findTests( return [...resolvedTestFiles]; } -interface TestEntrypointsOptions { +interface EntrypointsOptions { projectSourceRoot: string; workspaceRoot: string; removeTestExtension?: boolean; @@ -89,15 +89,49 @@ interface TestEntrypointsOptions { */ export function getTestEntrypoints( testFiles: string[], - { projectSourceRoot, workspaceRoot, removeTestExtension }: TestEntrypointsOptions, + { projectSourceRoot, workspaceRoot, removeTestExtension }: EntrypointsOptions, +): Map { + return getEntrypoints(testFiles, { + projectSourceRoot, + workspaceRoot, + removeTestExtension, + prefix: 'spec', + }); +} + +/** + * @param setupFiles An array of absolute paths to setup files. + * @param options Configuration options for generating entry points. + * @returns A map where keys are the generated unique bundle names and values are the original file paths. + */ +export function getSetupEntrypoints( + setupFiles: string[], + { projectSourceRoot, workspaceRoot, removeTestExtension }: EntrypointsOptions, +): Map { + return getEntrypoints(setupFiles, { + projectSourceRoot, + workspaceRoot, + removeTestExtension, + prefix: 'setup', + }); +} + +function getEntrypoints( + files: string[], + { + projectSourceRoot, + workspaceRoot, + removeTestExtension, + prefix, + }: EntrypointsOptions & { prefix: string }, ): Map { const seen = new Set(); const roots = [projectSourceRoot, workspaceRoot]; return new Map( - Array.from(testFiles, (testFile) => { - const fileName = generateNameFromPath(testFile, roots, !!removeTestExtension); - const baseName = `spec-${fileName}`; + Array.from(files, (setupFile) => { + const fileName = generateNameFromPath(setupFile, roots, !!removeTestExtension); + const baseName = `${prefix}-${fileName}`; let uniqueName = baseName; let suffix = 2; while (seen.has(uniqueName)) { @@ -106,7 +140,7 @@ export function getTestEntrypoints( } seen.add(uniqueName); - return [uniqueName, testFile]; + return [uniqueName, setupFile]; }), ); } @@ -136,7 +170,7 @@ function generateNameFromPath( let endIndex = relativePath.length; if (removeTestExtension) { const infixes = TEST_FILE_INFIXES.map((p) => p.substring(1)).join('|'); - const match = relativePath.match(new RegExp(`\\.(${infixes})\\.[^.]+$`)); + const match = relativePath.match(new RegExp(`\\.((${infixes})\\.)?[^.]+$`)); if (match?.index) { endIndex = match.index; 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..ab3f7ae9a8bc 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 @@ -10,13 +10,12 @@ import { execute } from '../../index'; import { BASE_OPTIONS, describeBuilder, - UNIT_TEST_BUILDER_INFO, setupApplicationTarget, - expectLog, + UNIT_TEST_BUILDER_INFO, } from '../setup'; describeBuilder(execute, UNIT_TEST_BUILDER_INFO, (harness) => { - xdescribe('Option: "setupFiles"', () => { + describe('Option: "setupFiles"', () => { beforeEach(async () => { setupApplicationTarget(harness); }); @@ -27,31 +26,84 @@ 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 } = await harness.executeOnce(); + expect(result?.success).toBeFalse(); + // TODO: Re-enable once Vite logs are remapped through build system + // expectLog(logs, `The specified setup file "src/setup.ts" does not exist.`); }); it('should include the setup files', async () => { await harness.writeFiles({ - 'src/setup.ts': `console.log('Hello from setup.ts');`, + 'src/setup.ts': `(globalThis as any).setupLoaded = true;`, 'src/app/app.component.spec.ts': ` import { describe, expect, test } from 'vitest' describe('AppComponent', () => { test('should create the app', () => { - expect(true).toBe(true); + expect((globalThis as any).setupLoaded).toBe(true); + }); + });`, + }); + + await harness.modifyFile('src/tsconfig.spec.json', (content) => { + const tsConfig = JSON.parse(content); + tsConfig.files ??= []; + tsConfig.files.push('setup.ts'); + return JSON.stringify(tsConfig); + }); + + harness.useTarget('test', { + ...BASE_OPTIONS, + setupFiles: ['src/setup.ts'], + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + }); + + it('should allow setup files to configure testing module', async () => { + await harness.writeFiles({ + 'src/setup.ts': ` + import { TestBed } from '@angular/core/testing'; + import { beforeEach } from 'vitest'; + import { SETUP_LOADED_TOKEN } from './setup-loaded-token'; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [{provide: SETUP_LOADED_TOKEN, useValue: true}], + }); + }); + `, + 'src/setup-loaded-token.ts': ` + import { InjectionToken } from '@angular/core'; + + export const SETUP_LOADED_TOKEN = new InjectionToken('SETUP_LOADED_TOKEN'); + `, + 'src/app/app.component.spec.ts': ` + import { beforeEach, describe, expect, test } from 'vitest'; + import { TestBed } from '@angular/core/testing'; + import { SETUP_LOADED_TOKEN } from '../setup-loaded-token'; + + describe('AppComponent', () => { + test('should create the app', () => { + expect(TestBed.inject(SETUP_LOADED_TOKEN)).toBe(true); }); });`, }); + await harness.modifyFile('src/tsconfig.spec.json', (content) => { + const tsConfig = JSON.parse(content); + tsConfig.files ??= []; + tsConfig.files.push('setup.ts'); + return JSON.stringify(tsConfig); + }); + harness.useTarget('test', { ...BASE_OPTIONS, 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'); }); }); });