diff --git a/packages/angular/build/src/builders/application/options.ts b/packages/angular/build/src/builders/application/options.ts index 322c2dbef897..464d2d85fc4a 100644 --- a/packages/angular/build/src/builders/application/options.ts +++ b/packages/angular/build/src/builders/application/options.ts @@ -140,9 +140,9 @@ interface InternalOptions { disableCodeSplitting?: boolean; /** - * An array of files to restrict the TypeScript compilation root names to. + * An array of files to exclude from the TypeScript compilation root names. */ - rootFiles?: string[]; + excludeRootFiles?: string[]; } /** Full set of options for `application` builder. */ @@ -456,7 +456,7 @@ export async function normalizeOptions( verbose, watch, progress = true, - rootFiles, + excludeRootFiles, externalPackages, namedChunks, budgets, @@ -504,7 +504,7 @@ export async function normalizeOptions( workspaceRoot, entryPoints, disableCodeSplitting, - rootFiles: rootFiles?.map((file: string) => path.resolve(workspaceRoot, file)), + excludeRootFiles: excludeRootFiles?.map((file: string) => path.resolve(workspaceRoot, file)), optimizationOptions, outputOptions, outExtension, 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 efff72e5d8bb..625c08b99dc0 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 @@ -21,6 +21,8 @@ import { type NormalizedUnitTestBuilderOptions, injectTestingPolyfills } from '. import { findTests, getTestEntrypoints } from '../../test-discovery'; import { RunnerOptions } from '../api'; +const DEFAULT_TEST_PATTERNS = ['**/*.spec.ts', '**/*.test.ts']; + /** * Creates the virtual file contents to initialize the Angular testing environment (TestBed). * @@ -210,14 +212,7 @@ export async function getVitestBuildOptions( removeTestExtension: true, }); - const rootFiles = [...testFiles]; - if (providersFile) { - rootFiles.push(providersFile); - } - if (setupFiles?.length) { - rootFiles.push(...setupFiles); - const setupEntryPoints = getTestEntrypoints(setupFiles, { projectSourceRoot, workspaceRoot, @@ -230,6 +225,34 @@ export async function getVitestBuildOptions( } } + let excludeRootFiles: string[] | undefined; + const isDefaultInclude = + exclude.length === 0 && + include.length === DEFAULT_TEST_PATTERNS.length && + include.every((val: string) => DEFAULT_TEST_PATTERNS.includes(val)); + + if (!isDefaultInclude) { + const allTestFiles = await findTests( + DEFAULT_TEST_PATTERNS, + [], + workspaceRoot, + projectSourceRoot, + ); + const testFilesSet = new Set(testFiles); + if (providersFile) { + testFilesSet.add(toPosixPath(providersFile)); + } + + if (setupFiles?.length) { + for (const setupFile of setupFiles) { + testFilesSet.add(toPosixPath(setupFile)); + } + } + + const unselectedTestFiles = allTestFiles.filter((file) => !testFilesSet.has(file)); + excludeRootFiles = unselectedTestFiles.length > 0 ? unselectedTestFiles : undefined; + } + // The Angular compiler facade must be loaded in a dedicated setup file before TestBed initialization. // This ensures the compiler facade is published before 'init-testbed' or any shared code-split chunks // (such as those from a configured 'providersFile') are evaluated. @@ -267,7 +290,7 @@ export async function getVitestBuildOptions( optimization: false, namedChunks: false, entryPoints, - rootFiles, + excludeRootFiles, // Vitest's Node-based module loading emulation (vite-node) is not fully spec compliant and lacks // live ESM bindings across chunk boundaries. This can cause uninitialized exports or break mocking. // Disabling code splitting avoids shared chunks, but increases build and coverage memory/time. diff --git a/packages/angular/build/src/builders/unit-test/tests/options/include_spec.ts b/packages/angular/build/src/builders/unit-test/tests/options/include_spec.ts index d3d4e19a30f9..b01ad6d17196 100644 --- a/packages/angular/build/src/builders/unit-test/tests/options/include_spec.ts +++ b/packages/angular/build/src/builders/unit-test/tests/options/include_spec.ts @@ -110,5 +110,77 @@ describeBuilder(execute, UNIT_TEST_BUILDER_INFO, (harness) => { const { result } = await harness.executeOnce(); expect(result?.success).toBeTrue(); }); + + it('should compile NgModule declared components when using include in an NgModule app', async () => { + await harness.writeFiles({ + 'src/tsconfig.spec.json': JSON.stringify({ + extends: '../tsconfig.json', + compilerOptions: { + outDir: '../out-tsc/spec', + types: ['vitest/globals'], + }, + include: ['**/*.d.ts', '**/*.ts'], + }), + 'src/app/app.module.ts': ` + import { NgModule } from '@angular/core'; + import { BrowserModule } from '@angular/platform-browser'; + import { RouterModule } from '@angular/router'; + import { AppComponent } from './app.component'; + + @NgModule({ + declarations: [AppComponent], + imports: [BrowserModule, RouterModule], + }) + export class AppModule {} + `, + 'src/app/app.component.ts': ` + import { Component } from '@angular/core'; + + @Component({ + selector: 'app-root', + standalone: false, + templateUrl: './app.component.html', + }) + export class AppComponent {} + `, + 'src/app/app.component.html': '', + 'src/app/app.component.spec.ts': ` + import { TestBed } from '@angular/core/testing'; + import { RouterModule } from '@angular/router'; + import { AppComponent } from './app.component'; + + describe('AppComponent', () => { + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [RouterModule.forRoot([])], + declarations: [AppComponent], + }).compileComponents(); + }); + + it('should create the app', () => { + const fixture = TestBed.createComponent(AppComponent); + const app = fixture.componentInstance; + expect(app).toBeTruthy(); + }); + }); + `, + 'src/app/broken.service.spec.ts': ` + // This test has a TypeScript type error that would fail compilation if compiled + const invalidNumber: number = 'not a number'; + describe('BrokenService', () => { + it('should fail compilation', () => { + expect(invalidNumber).toBe(1); + }); + });`, + }); + + harness.useTarget('test', { + ...BASE_OPTIONS, + include: ['src/app/app.component.spec.ts'], + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + }); }); }); diff --git a/packages/angular/build/src/builders/unit-test/tests/options/tsconfig_spec.ts b/packages/angular/build/src/builders/unit-test/tests/options/tsconfig_spec.ts index 101f07c1d12e..5e98ad3c6d2b 100644 --- a/packages/angular/build/src/builders/unit-test/tests/options/tsconfig_spec.ts +++ b/packages/angular/build/src/builders/unit-test/tests/options/tsconfig_spec.ts @@ -55,5 +55,68 @@ describeBuilder(execute, UNIT_TEST_BUILDER_INFO, (harness) => { expect(result?.success).toBeFalse(); expectLog(logs, `The specified tsConfig file 'random/tsconfig.spec.json' does not exist.`); }); + + it('should compile NgModule declared components when tsConfig includes application files', async () => { + await harness.writeFiles({ + 'src/tsconfig.spec.json': JSON.stringify({ + extends: '../tsconfig.json', + compilerOptions: { + outDir: '../out-tsc/spec', + types: ['vitest/globals'], + }, + include: ['**/*.d.ts', '**/*.ts'], + }), + 'src/app/app.module.ts': ` + import { NgModule } from '@angular/core'; + import { BrowserModule } from '@angular/platform-browser'; + import { RouterModule } from '@angular/router'; + import { AppComponent } from './app.component'; + + @NgModule({ + declarations: [AppComponent], + imports: [BrowserModule, RouterModule], + }) + export class AppModule {} + `, + 'src/app/app.component.ts': ` + import { Component } from '@angular/core'; + + @Component({ + selector: 'app-root', + standalone: false, + templateUrl: './app.component.html', + }) + export class AppComponent {} + `, + 'src/app/app.component.html': '', + 'src/app/app.component.spec.ts': ` + import { TestBed } from '@angular/core/testing'; + import { RouterModule } from '@angular/router'; + import { AppComponent } from './app.component'; + + describe('AppComponent', () => { + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [RouterModule.forRoot([])], + declarations: [AppComponent], + }).compileComponents(); + }); + + it('should create the app', () => { + const fixture = TestBed.createComponent(AppComponent); + const app = fixture.componentInstance; + expect(app).toBeTruthy(); + }); + }); + `, + }); + + harness.useTarget('test', { + ...BASE_OPTIONS, + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + }); }); }); diff --git a/packages/angular/build/src/tools/angular/compilation/compiler-options.ts b/packages/angular/build/src/tools/angular/compilation/compiler-options.ts index 1720a8bb0516..46ea2de75857 100644 --- a/packages/angular/build/src/tools/angular/compilation/compiler-options.ts +++ b/packages/angular/build/src/tools/angular/compilation/compiler-options.ts @@ -21,6 +21,7 @@ export interface CompilerOptionOverrides { includeTestMetadata?: boolean; customConditions?: string[]; rootFiles?: string[]; + excludeRootFiles?: string[]; declarationMap?: boolean; compilationMode?: 'full' | 'partial'; paths?: Record; diff --git a/packages/angular/build/src/tools/angular/compilation/typescript-compilation.ts b/packages/angular/build/src/tools/angular/compilation/typescript-compilation.ts index 2595bc60f55a..eb313de14e9d 100644 --- a/packages/angular/build/src/tools/angular/compilation/typescript-compilation.ts +++ b/packages/angular/build/src/tools/angular/compilation/typescript-compilation.ts @@ -66,14 +66,24 @@ export abstract class TypeScriptCompilation extends AngularCompilation { const rootFilesSet = new Set( compilerOptionOverrides.rootFiles.map((file) => canonicalizePath(toPosixPath(file))), ); + for (const file of originalRootNames) { if (/\.d\.[cm]?ts$/i.test(file)) { rootFilesSet.add(canonicalizePath(toPosixPath(file))); } } + rootNames = [...rootFilesSet]; } + if (compilerOptionOverrides?.excludeRootFiles?.length) { + const excludeSet = new Set( + compilerOptionOverrides.excludeRootFiles.map((file) => canonicalizePath(toPosixPath(file))), + ); + + rootNames = rootNames.filter((file) => !excludeSet.has(canonicalizePath(toPosixPath(file)))); + } + const { compilerOptions, warnings } = transformCompilerOptions( ts, originalCompilerOptions, diff --git a/packages/angular/build/src/tools/esbuild/angular/compiler-plugin.ts b/packages/angular/build/src/tools/esbuild/angular/compiler-plugin.ts index 9a36eea56d61..14604e50ecef 100644 --- a/packages/angular/build/src/tools/esbuild/angular/compiler-plugin.ts +++ b/packages/angular/build/src/tools/esbuild/angular/compiler-plugin.ts @@ -61,7 +61,7 @@ export interface CompilerPluginOptions { externalRuntimeStyles?: boolean; instrumentForCoverage?: (request: string) => boolean; templateUpdates?: Map; - rootFiles?: string[]; + excludeRootFiles?: string[]; } // eslint-disable-next-line max-lines-per-function @@ -330,7 +330,7 @@ export function createCompilerPlugin( instrumentForCoverage: !!pluginOptions.instrumentForCoverage, includeTestMetadata: !!pluginOptions.includeTestMetadata, customConditions: build.initialOptions.conditions, - rootFiles: pluginOptions.rootFiles, + excludeRootFiles: pluginOptions.excludeRootFiles, }, ); diff --git a/packages/angular/build/src/tools/esbuild/compiler-plugin-options.ts b/packages/angular/build/src/tools/esbuild/compiler-plugin-options.ts index 573b8616da60..744383b8c941 100644 --- a/packages/angular/build/src/tools/esbuild/compiler-plugin-options.ts +++ b/packages/angular/build/src/tools/esbuild/compiler-plugin-options.ts @@ -28,7 +28,7 @@ export function createCompilerPluginOptions( externalRuntimeStyles, instrumentForCoverage, optimizationOptions, - rootFiles, + excludeRootFiles, } = options; const incremental = !!options.watch; @@ -46,6 +46,6 @@ export function createCompilerPluginOptions( instrumentForCoverage, templateUpdates, includeTestMetadata: !optimizationOptions.scripts, - rootFiles, + excludeRootFiles, }; }