Skip to content

Commit 6be51ea

Browse files
committed
fix(@angular/build): resolve unimported NgModule scopes when filtering unit-test files
When `rootFiles` overrides are provided (such as during `unit-test` runs), the initial TypeScript program is restricted to those entry points and their transitive imports. If a test imports a non-standalone `@Component` directly without importing its declaring `@NgModule`, the `NgModule` may be omitted from the program and leave the component without its compilation scope. Preserve the full `tsconfig` root file set as `fallbackRootNames` for application builds when `rootFiles` is a subset of the `tsconfig` files. If AOT analysis detects a non-standalone component lacking an owning `NgModule` scope (`getOwningNgModule(component) === null`) and additional uncompiled `tsconfig` files exist, recreate and re-analyze the Angular program with the full root file set so all `NgModule` declarations can be discovered.
1 parent 1f31103 commit 6be51ea

4 files changed

Lines changed: 354 additions & 9 deletions

File tree

‎packages/angular/build/src/builders/unit-test/tests/options/include_spec.ts‎

Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,5 +110,172 @@ describeBuilder(execute, UNIT_TEST_BUILDER_INFO, (harness) => {
110110
const { result } = await harness.executeOnce();
111111
expect(result?.success).toBeTrue();
112112
});
113+
114+
it('should not compile unimported application files when testing standalone code', async () => {
115+
await harness.writeFiles({
116+
'src/tsconfig.spec.json': JSON.stringify({
117+
extends: '../tsconfig.json',
118+
compilerOptions: {
119+
outDir: '../out-tsc/spec',
120+
types: ['jasmine', '@angular/localize'],
121+
},
122+
files: ['polyfills.ts'],
123+
include: ['**/*.ts', '**/*.d.ts'],
124+
}),
125+
'src/app/services/test.service.spec.ts': `
126+
describe('TestService', () => {
127+
it('should succeed', () => {
128+
expect(true).toBe(true);
129+
});
130+
});`,
131+
'src/app/unused-broken.ts': `
132+
// This unimported file has a TypeScript error and should not be compiled for standalone tests
133+
export const invalidNumber: number = 'not a number';
134+
`,
135+
});
136+
137+
harness.useTarget('test', {
138+
...BASE_OPTIONS,
139+
include: ['src/app/services/test.service.spec.ts'],
140+
});
141+
142+
const { result } = await harness.executeOnce();
143+
expect(result?.success).toBeTrue();
144+
});
145+
146+
it('should resolve NgModule scope when testing a non-standalone component without importing the NgModule', async () => {
147+
await harness.writeFiles({
148+
'src/tsconfig.spec.json': JSON.stringify({
149+
extends: '../tsconfig.json',
150+
compilerOptions: {
151+
outDir: '../out-tsc/spec',
152+
types: ['jasmine', '@angular/localize'],
153+
},
154+
files: ['polyfills.ts'],
155+
include: ['**/*.ts', '**/*.d.ts'],
156+
}),
157+
'src/app/app.ts': `
158+
import { Component } from '@angular/core';
159+
160+
@Component({
161+
selector: 'app-root',
162+
template: '<h1>Hello, {{ title }}</h1><router-outlet />',
163+
standalone: false,
164+
})
165+
export class App {
166+
title = 'test';
167+
}
168+
`,
169+
'src/app/app-module.ts': `
170+
import { NgModule } from '@angular/core';
171+
import { BrowserModule } from '@angular/platform-browser';
172+
import { RouterModule } from '@angular/router';
173+
import { App } from './app';
174+
175+
@NgModule({
176+
declarations: [App],
177+
imports: [BrowserModule, RouterModule.forRoot([])],
178+
bootstrap: [App],
179+
})
180+
export class AppModule {}
181+
`,
182+
'src/app/app.spec.ts': `
183+
import { TestBed } from '@angular/core/testing';
184+
import { RouterModule } from '@angular/router';
185+
import { App } from './app';
186+
187+
describe('App', () => {
188+
beforeEach(async () => {
189+
await TestBed.configureTestingModule({
190+
imports: [RouterModule.forRoot([])],
191+
declarations: [App],
192+
}).compileComponents();
193+
});
194+
195+
it('should create the app', () => {
196+
const fixture = TestBed.createComponent(App);
197+
expect(fixture.componentInstance).toBeTruthy();
198+
});
199+
});
200+
`,
201+
});
202+
203+
harness.useTarget('test', {
204+
...BASE_OPTIONS,
205+
include: ['src/app/app.spec.ts'],
206+
});
207+
208+
const { result } = await harness.executeOnce();
209+
expect(result?.success).toBeTrue();
210+
});
211+
212+
it('should not compile unimported files when a non-standalone component NgModule is imported by the test', async () => {
213+
await harness.writeFiles({
214+
'src/tsconfig.spec.json': JSON.stringify({
215+
extends: '../tsconfig.json',
216+
compilerOptions: {
217+
outDir: '../out-tsc/spec',
218+
types: ['jasmine', '@angular/localize'],
219+
},
220+
files: ['polyfills.ts'],
221+
include: ['**/*.ts', '**/*.d.ts'],
222+
}),
223+
'src/app/app.ts': `
224+
import { Component } from '@angular/core';
225+
226+
@Component({
227+
selector: 'app-root',
228+
template: '<h1>Hello, {{ title }}</h1><router-outlet />',
229+
standalone: false,
230+
})
231+
export class App {
232+
title = 'test';
233+
}
234+
`,
235+
'src/app/app-module.ts': `
236+
import { NgModule } from '@angular/core';
237+
import { BrowserModule } from '@angular/platform-browser';
238+
import { RouterModule } from '@angular/router';
239+
import { App } from './app';
240+
241+
@NgModule({
242+
declarations: [App],
243+
imports: [BrowserModule, RouterModule.forRoot([])],
244+
bootstrap: [App],
245+
})
246+
export class AppModule {}
247+
`,
248+
'src/app/app.spec.ts': `
249+
import { TestBed } from '@angular/core/testing';
250+
import { App } from './app';
251+
import { AppModule } from './app-module';
252+
253+
describe('App', () => {
254+
beforeEach(async () => {
255+
await TestBed.configureTestingModule({
256+
imports: [AppModule],
257+
}).compileComponents();
258+
});
259+
260+
it('should create the app', () => {
261+
const fixture = TestBed.createComponent(App);
262+
expect(fixture.componentInstance).toBeTruthy();
263+
});
264+
});
265+
`,
266+
'src/app/unused-broken.ts': `
267+
// This unimported file has a TypeScript error and should not be compiled when AppModule is already imported
268+
export const invalidNumber: number = 'not a number';
269+
`,
270+
});
271+
272+
harness.useTarget('test', {
273+
...BASE_OPTIONS,
274+
include: ['src/app/app.spec.ts'],
275+
});
276+
277+
const { result } = await harness.executeOnce();
278+
expect(result?.success).toBeTrue();
279+
});
113280
});
114281
});

‎packages/angular/build/src/tools/angular/compilation/angular-compilation_spec.ts‎

Lines changed: 77 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -91,8 +91,12 @@ describe('AngularCompilation', () => {
9191
return this.sourceFiles;
9292
}
9393

94-
public async testLoadConfiguration(tsconfig: string, overrides?: CompilerOptionOverrides) {
95-
return this.loadConfiguration(tsconfig, overrides);
94+
public async testLoadConfiguration(
95+
tsconfig: string,
96+
overrides?: CompilerOptionOverrides,
97+
buildType?: 'application' | 'library',
98+
) {
99+
return this.loadConfiguration(tsconfig, overrides, buildType);
96100
}
97101
}
98102

@@ -162,12 +166,83 @@ describe('AngularCompilation', () => {
162166
jasmine.any(Map),
163167
);
164168
expect(result.rootNames).toEqual(['/src/main.ts']);
169+
expect(result.fallbackRootNames).toBeUndefined();
165170
expect(result.compilerOptions.target).toBe(ts.ScriptTarget.ES2022);
166171
expect(result.compilerOptions.inlineSources).toBe(true);
167172
expect(result.warnings.length).toBeGreaterThan(0);
168173
expect(result.tsConfigFiles).toEqual(['tsconfig.json']);
169174
});
170175

176+
it('filters rootNames to rootFiles while retaining ambient declaration files and setting fallbackRootNames', async () => {
177+
const compilation = new MockTypeScriptCompilation();
178+
const mockReadConfig = jasmine.createSpy('readConfiguration').and.returnValue({
179+
options: { target: ts.ScriptTarget.ES2022 },
180+
rootNames: [
181+
'/src/app/included.spec.ts',
182+
'/src/app/excluded.spec.ts',
183+
'/src/app/app-module.ts',
184+
'/src/env.d.ts',
185+
'/src/types.d.mts',
186+
'/src/legacy.d.cts',
187+
],
188+
errors: [],
189+
});
190+
spyOn(TypeScriptCompilation, 'loadCompilerCli').and.resolveTo({
191+
readConfiguration: mockReadConfig,
192+
} as unknown as typeof import('@angular/compiler-cli'));
193+
194+
const result = await compilation.testLoadConfiguration('tsconfig.spec.json', {
195+
rootFiles: ['/src/app/included.spec.ts'],
196+
});
197+
198+
expect(result.rootNames).toEqual([
199+
'/src/app/included.spec.ts',
200+
'/src/env.d.ts',
201+
'/src/types.d.mts',
202+
'/src/legacy.d.cts',
203+
]);
204+
expect(result.fallbackRootNames).toEqual([
205+
'/src/app/included.spec.ts',
206+
'/src/app/excluded.spec.ts',
207+
'/src/app/app-module.ts',
208+
'/src/env.d.ts',
209+
'/src/types.d.mts',
210+
'/src/legacy.d.cts',
211+
]);
212+
});
213+
214+
it('omits fallbackRootNames when buildType is "library" or when rootFiles covers all tsconfig files', async () => {
215+
const compilation = new MockTypeScriptCompilation();
216+
const mockReadConfig = jasmine.createSpy('readConfiguration').and.returnValue({
217+
options: { target: ts.ScriptTarget.ES2022 },
218+
rootNames: ['/src/public-api.ts', '/src/internal.ts', '/src/env.d.ts'],
219+
errors: [],
220+
});
221+
spyOn(TypeScriptCompilation, 'loadCompilerCli').and.resolveTo({
222+
readConfiguration: mockReadConfig,
223+
} as unknown as typeof import('@angular/compiler-cli'));
224+
225+
const libraryResult = await compilation.testLoadConfiguration(
226+
'tsconfig.lib.json',
227+
{ rootFiles: ['/src/public-api.ts'] },
228+
'library',
229+
);
230+
expect(libraryResult.rootNames).toEqual(['/src/public-api.ts', '/src/env.d.ts']);
231+
expect(libraryResult.fallbackRootNames).toBeUndefined();
232+
233+
const fullSetResult = await compilation.testLoadConfiguration(
234+
'tsconfig.app.json',
235+
{ rootFiles: ['/src/public-api.ts', '/src/internal.ts'] },
236+
'application',
237+
);
238+
expect(fullSetResult.rootNames).toEqual([
239+
'/src/public-api.ts',
240+
'/src/internal.ts',
241+
'/src/env.d.ts',
242+
]);
243+
expect(fullSetResult.fallbackRootNames).toBeUndefined();
244+
});
245+
171246
it('passes extendedConfigCache to readConfiguration and invalidates entries on config change', async () => {
172247
const compilation = new MockTypeScriptCompilation();
173248
let passedCache: Map<string, ts.ExtendedConfigCacheEntry> | undefined;

0 commit comments

Comments
 (0)