Skip to content

Commit 9724169

Browse files
AndrewKushnirmatsko
authored andcommitted
fix(core): properly identify modules affected by overrides in TestBed (#36649)
When module overrides (via `TestBed.overrideModule`) are present, it might affect all modules that import (even transitively) an overridden one. For all affected modules we need to recalculate their scopes for a given test run and restore original scopes at the end. Prior to this change, we were recalculating module scopes only for components that are used in a test, without taking into account module hierarchy. This commit updates Ivy TestBed logic to calculate all potentially affected modules are reset cached scopes information for them (so that scopes are recalculated as needed). Resolves #36619. PR Close #36649
1 parent c0ed57d commit 9724169

File tree

2 files changed

+178
-11
lines changed

2 files changed

+178
-11
lines changed

packages/core/test/test_bed_spec.ts

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -359,6 +359,126 @@ describe('TestBed', () => {
359359
});
360360
});
361361

362+
describe('nested module overrides using TestBed.overrideModule', () => {
363+
// Set up an NgModule hierarchy with two modules, A and B, each with their own component.
364+
// Module B additionally re-exports module A. Also declare two mock components which can be
365+
// used in tests to verify that overrides within this hierarchy are working correctly.
366+
367+
// ModuleA content:
368+
369+
@Component({
370+
selector: 'comp-a',
371+
template: 'comp-a content',
372+
})
373+
class CompA {
374+
}
375+
376+
@Component({
377+
selector: 'comp-a',
378+
template: 'comp-a mock content',
379+
})
380+
class MockCompA {
381+
}
382+
383+
@NgModule({
384+
declarations: [CompA],
385+
exports: [CompA],
386+
})
387+
class ModuleA {
388+
}
389+
390+
// ModuleB content:
391+
392+
@Component({
393+
selector: 'comp-b',
394+
template: 'comp-b content',
395+
})
396+
class CompB {
397+
}
398+
399+
@Component({
400+
selector: 'comp-b',
401+
template: 'comp-b mock content',
402+
})
403+
class MockCompB {
404+
}
405+
406+
@NgModule({
407+
imports: [ModuleA],
408+
declarations: [CompB],
409+
exports: [CompB, ModuleA],
410+
})
411+
class ModuleB {
412+
}
413+
414+
// AppModule content:
415+
416+
@Component({
417+
selector: 'app',
418+
template: `
419+
<comp-a></comp-a>
420+
<comp-b></comp-b>
421+
`,
422+
})
423+
class App {
424+
}
425+
426+
@NgModule({
427+
imports: [ModuleB],
428+
exports: [ModuleB],
429+
})
430+
class AppModule {
431+
}
432+
433+
it('should detect nested module override', () => {
434+
TestBed
435+
.configureTestingModule({
436+
declarations: [App],
437+
// AppModule -> ModuleB -> ModuleA (to be overridden)
438+
imports: [AppModule],
439+
})
440+
.overrideModule(ModuleA, {
441+
remove: {declarations: [CompA], exports: [CompA]},
442+
add: {declarations: [MockCompA], exports: [MockCompA]}
443+
})
444+
.compileComponents();
445+
446+
const fixture = TestBed.createComponent(App);
447+
fixture.detectChanges();
448+
449+
// CompA is overridden, expect mock content.
450+
expect(fixture.nativeElement.textContent).toContain('comp-a mock content');
451+
452+
// CompB is not overridden, expect original content.
453+
expect(fixture.nativeElement.textContent).toContain('comp-b content');
454+
});
455+
456+
it('should detect chained modules override', () => {
457+
TestBed
458+
.configureTestingModule({
459+
declarations: [App],
460+
// AppModule -> ModuleB (to be overridden) -> ModuleA (to be overridden)
461+
imports: [AppModule],
462+
})
463+
.overrideModule(ModuleA, {
464+
remove: {declarations: [CompA], exports: [CompA]},
465+
add: {declarations: [MockCompA], exports: [MockCompA]}
466+
})
467+
.overrideModule(ModuleB, {
468+
remove: {declarations: [CompB], exports: [CompB]},
469+
add: {declarations: [MockCompB], exports: [MockCompB]}
470+
})
471+
.compileComponents();
472+
473+
const fixture = TestBed.createComponent(App);
474+
fixture.detectChanges();
475+
476+
// Both CompA and CompB are overridden, expect mock content for both.
477+
expect(fixture.nativeElement.textContent).toContain('comp-a mock content');
478+
expect(fixture.nativeElement.textContent).toContain('comp-b mock content');
479+
});
480+
});
481+
362482
describe('multi providers', () => {
363483
const multiToken = new InjectionToken<string[]>('multiToken');
364484
const singleToken = new InjectionToken<string>('singleToken');

packages/core/testing/src/r3_test_bed_compiler.ts

Lines changed: 58 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,9 @@ export class R3TestBedCompiler {
5757
private seenComponents = new Set<Type<any>>();
5858
private seenDirectives = new Set<Type<any>>();
5959

60+
// Keep track of overridden modules, so that we can collect all affected ones in the module tree.
61+
private overriddenModules = new Set<NgModuleType<any>>();
62+
6063
// Store resolved styles for Components that have template overrides present and `styleUrls`
6164
// defined at the same time.
6265
private existingComponentStyles = new Map<Type<any>, string[]>();
@@ -88,7 +91,6 @@ export class R3TestBedCompiler {
8891

8992
private testModuleType: NgModuleType<any>;
9093
private testModuleRef: NgModuleRef<any>|null = null;
91-
private hasModuleOverrides: boolean = false;
9294

9395
constructor(private platform: PlatformRef, private additionalModuleTypes: Type<any>|Type<any>[]) {
9496
class DynamicTestModule {}
@@ -123,7 +125,7 @@ export class R3TestBedCompiler {
123125
}
124126

125127
overrideModule(ngModule: Type<any>, override: MetadataOverride<NgModule>): void {
126-
this.hasModuleOverrides = true;
128+
this.overriddenModules.add(ngModule as NgModuleType<any>);
127129

128130
// Compile the module right away.
129131
this.resolvers.module.addOverride(ngModule, override);
@@ -348,21 +350,26 @@ export class R3TestBedCompiler {
348350
}
349351

350352
private applyTransitiveScopes(): void {
353+
if (this.overriddenModules.size > 0) {
354+
// Module overrides (via `TestBed.overrideModule`) might affect scopes that were previously
355+
// calculated and stored in `transitiveCompileScopes`. If module overrides are present,
356+
// collect all affected modules and reset scopes to force their re-calculatation.
357+
const testingModuleDef = (this.testModuleType as any)[NG_MOD_DEF];
358+
const affectedModules = this.collectModulesAffectedByOverrides(testingModuleDef.imports);
359+
if (affectedModules.size > 0) {
360+
affectedModules.forEach(moduleType => {
361+
this.storeFieldOfDefOnType(moduleType as any, NG_MOD_DEF, 'transitiveCompileScopes');
362+
(moduleType as any)[NG_MOD_DEF].transitiveCompileScopes = null;
363+
});
364+
}
365+
}
366+
351367
const moduleToScope = new Map<Type<any>|TestingModuleOverride, NgModuleTransitiveScopes>();
352368
const getScopeOfModule =
353369
(moduleType: Type<any>|TestingModuleOverride): NgModuleTransitiveScopes => {
354370
if (!moduleToScope.has(moduleType)) {
355371
const isTestingModule = isTestingModuleOverride(moduleType);
356372
const realType = isTestingModule ? this.testModuleType : moduleType as Type<any>;
357-
// Module overrides (via TestBed.overrideModule) might affect scopes that were
358-
// previously calculated and stored in `transitiveCompileScopes`. If module overrides
359-
// are present, always re-calculate transitive scopes to have the most up-to-date
360-
// information available. The `moduleToScope` map avoids repeated re-calculation of
361-
// scopes for the same module.
362-
if (!isTestingModule && this.hasModuleOverrides) {
363-
this.storeFieldOfDefOnType(moduleType as any, NG_MOD_DEF, 'transitiveCompileScopes');
364-
(moduleType as any)[NG_MOD_DEF].transitiveCompileScopes = null;
365-
}
366373
moduleToScope.set(moduleType, transitiveScopesFor(realType));
367374
}
368375
return moduleToScope.get(moduleType)!;
@@ -532,6 +539,46 @@ export class R3TestBedCompiler {
532539
queueTypesFromModulesArrayRecur(arr);
533540
}
534541

542+
// When module overrides (via `TestBed.overrideModule`) are present, it might affect all modules
543+
// that import (even transitively) an overridden one. For all affected modules we need to
544+
// recalculate their scopes for a given test run and restore original scopes at the end. The goal
545+
// of this function is to collect all affected modules in a set for further processing. Example:
546+
// if we have the following module hierarchy: A -> B -> C (where `->` means `imports`) and module
547+
// `C` is overridden, we consider `A` and `B` as affected, since their scopes might become
548+
// invalidated with the override.
549+
private collectModulesAffectedByOverrides(arr: any[]): Set<NgModuleType<any>> {
550+
const seenModules = new Set<NgModuleType<any>>();
551+
const affectedModules = new Set<NgModuleType<any>>();
552+
const calcAffectedModulesRecur = (arr: any[], path: NgModuleType<any>[]): void => {
553+
for (const value of arr) {
554+
if (Array.isArray(value)) {
555+
// If the value is an array, just flatten it (by invoking this function recursively),
556+
// keeping "path" the same.
557+
calcAffectedModulesRecur(value, path);
558+
} else if (hasNgModuleDef(value)) {
559+
if (seenModules.has(value)) {
560+
// If we've seen this module before and it's included into "affected modules" list, mark
561+
// the whole path that leads to that module as affected, but do not descend into its
562+
// imports, since we already examined them before.
563+
if (affectedModules.has(value)) {
564+
path.forEach(item => affectedModules.add(item));
565+
}
566+
continue;
567+
}
568+
seenModules.add(value);
569+
if (this.overriddenModules.has(value)) {
570+
path.forEach(item => affectedModules.add(item));
571+
}
572+
// Examine module imports recursively to look for overridden modules.
573+
const moduleDef = (value as any)[NG_MOD_DEF];
574+
calcAffectedModulesRecur(maybeUnwrapFn(moduleDef.imports), path.concat(value));
575+
}
576+
}
577+
};
578+
calcAffectedModulesRecur(arr, []);
579+
return affectedModules;
580+
}
581+
535582
private maybeStoreNgDef(prop: string, type: Type<any>) {
536583
if (!this.initialNgDefs.has(type)) {
537584
const currentDef = Object.getOwnPropertyDescriptor(type, prop);

0 commit comments

Comments
 (0)