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,
};
}