From 401dec46eb71e33ae3ef185b8f92ed2b3b7661fd Mon Sep 17 00:00:00 2001 From: Andrew Kushnir Date: Wed, 27 Apr 2022 21:47:59 -0700 Subject: [PATCH] feat(core): update TestBed to recognize Standalone Components (#45809) This commit updates an internal logic of the TestBed to recognize Standalone Components to be able to apply the necessary overrides correctly. PR Close #45809 --- packages/core/test/test_bed_spec.ts | 248 ++++++++++++++++++ packages/core/testing/src/r3_test_bed.ts | 3 +- .../core/testing/src/r3_test_bed_compiler.ts | 43 ++- 3 files changed, 288 insertions(+), 6 deletions(-) diff --git a/packages/core/test/test_bed_spec.ts b/packages/core/test/test_bed_spec.ts index d47991a2883..b88a4b7399b 100644 --- a/packages/core/test/test_bed_spec.ts +++ b/packages/core/test/test_bed_spec.ts @@ -168,6 +168,254 @@ describe('TestBed', () => { }); }); +describe('TestBed with Standalone types', () => { + beforeEach(() => { + getTestBed().resetTestingModule(); + }); + + it('should override providers on standalone component itself', () => { + const A = new InjectionToken('A'); + + @Component({ + standalone: true, + template: '{{ a }}', + providers: [{provide: A, useValue: 'A'}], + }) + class MyStandaloneComp { + constructor(@Inject(A) public a: string) {} + } + + // NOTE: the `TestBed.configureTestingModule` is load-bearing here: it instructs + // TestBed to examine and override providers in dependencies. + TestBed.configureTestingModule({imports: [MyStandaloneComp]}); + TestBed.overrideProvider(A, {useValue: 'Overridden A'}); + + const fixture = TestBed.createComponent(MyStandaloneComp); + fixture.detectChanges(); + + expect(fixture.nativeElement.innerHTML).toBe('Overridden A'); + }); + + it('should override providers in standalone component dependencies via overrideProvider', () => { + const A = new InjectionToken('A'); + @NgModule({ + providers: [{provide: A, useValue: 'A'}], + }) + class ComponentDependenciesModule { + } + + @Component({ + standalone: true, + template: '{{ a }}', + imports: [ComponentDependenciesModule], + }) + class MyStandaloneComp { + constructor(@Inject(A) public a: string) {} + } + + // NOTE: the `TestBed.configureTestingModule` is load-bearing here: it instructs + // TestBed to examine and override providers in dependencies. + TestBed.configureTestingModule({imports: [MyStandaloneComp]}); + TestBed.overrideProvider(A, {useValue: 'Overridden A'}); + + const fixture = TestBed.createComponent(MyStandaloneComp); + fixture.detectChanges(); + + expect(fixture.nativeElement.innerHTML).toBe('Overridden A'); + }); + + it('should override providers in standalone component dependencies via overrideModule', () => { + const A = new InjectionToken('A'); + @NgModule({ + providers: [{provide: A, useValue: 'A'}], + }) + class ComponentDependenciesModule { + } + + @Component({ + standalone: true, + template: '{{ a }}', + imports: [ComponentDependenciesModule], + }) + class MyStandaloneComp { + constructor(@Inject(A) public a: string) {} + } + + // NOTE: the `TestBed.configureTestingModule` is *not* needed here, since the TestBed + // knows which NgModule was overridden and needs re-compilation. + TestBed.overrideModule( + ComponentDependenciesModule, {set: {providers: [{provide: A, useValue: 'Overridden A'}]}}); + + const fixture = TestBed.createComponent(MyStandaloneComp); + fixture.detectChanges(); + + expect(fixture.nativeElement.innerHTML).toBe('Overridden A'); + }); + + it('should allow overriding a template of a standalone component', () => { + @Component({ + standalone: true, + template: 'Original', + }) + class MyStandaloneComp { + } + + // NOTE: the `TestBed.configureTestingModule` call is *not* required here, since TestBed already + // knows that the `MyStandaloneComp` should be overridden/recompiled. + TestBed.overrideComponent(MyStandaloneComp, {set: {template: 'Overridden'}}); + + const fixture = TestBed.createComponent(MyStandaloneComp); + fixture.detectChanges(); + + expect(fixture.nativeElement.innerHTML).toBe('Overridden'); + }); + + it('should allow overriding the set of directives and pipes used in a standalone component', + () => { + @Directive({ + selector: '[dir]', + standalone: true, + host: {'[id]': 'id'}, + }) + class MyStandaloneDirectiveA { + id = 'A'; + } + + @Directive({ + selector: '[dir]', + standalone: true, + host: {'[id]': 'id'}, + }) + class MyStandaloneDirectiveB { + id = 'B'; + } + + @Pipe({name: 'pipe', standalone: true}) + class MyStandalonePipeA { + transform(value: string): string { + return `transformed ${value} (A)`; + } + } + @Pipe({name: 'pipe', standalone: true}) + class MyStandalonePipeB { + transform(value: string): string { + return `transformed ${value} (B)`; + } + } + + @Component({ + standalone: true, + template: '
{{ name | pipe }}
', + imports: [MyStandalonePipeA, MyStandaloneDirectiveA], + }) + class MyStandaloneComp { + name = 'MyStandaloneComp'; + } + + // NOTE: the `TestBed.configureTestingModule` call is *not* required here, since TestBed + // already knows that the `MyStandaloneComp` should be overridden/recompiled. + TestBed.overrideComponent( + MyStandaloneComp, {set: {imports: [MyStandalonePipeB, MyStandaloneDirectiveB]}}); + + const fixture = TestBed.createComponent(MyStandaloneComp); + fixture.detectChanges(); + + const rootElement = fixture.nativeElement.firstChild; + expect(rootElement.id).toBe('B'); + expect(rootElement.innerHTML).toBe('transformed MyStandaloneComp (B)'); + }); + + it('should reflect overrides on imported standalone directive', () => { + @Directive({ + selector: '[dir]', + standalone: true, + host: {'[id]': 'id'}, + }) + class DepStandaloneDirective { + id = 'A'; + } + + @Component({ + selector: 'standalone-cmp', + standalone: true, + template: 'Original MyStandaloneComponent', + }) + class DepStandaloneComponent { + id = 'A'; + } + + @Component({ + standalone: true, + template: 'Hello world!', + imports: [DepStandaloneDirective, DepStandaloneComponent], + }) + class RootStandaloneComp { + } + + // NOTE: the `TestBed.configureTestingModule` call is *not* required here, since TestBed + // already knows which Components/Directives are overridden and should be recompiled. + TestBed.overrideComponent( + DepStandaloneComponent, {set: {template: 'Overridden MyStandaloneComponent'}}); + TestBed.overrideDirective(DepStandaloneDirective, {set: {host: {'[id]': '\'Overridden\''}}}); + + const fixture = TestBed.createComponent(RootStandaloneComp); + fixture.detectChanges(); + + const rootElement = fixture.nativeElement.firstChild; + + expect(rootElement.id).toBe('Overridden'); + expect(rootElement.innerHTML).toBe('Overridden MyStandaloneComponent'); + }); + + describe('NgModules as dependencies', () => { + @Component({ + selector: 'test-cmp', + template: '...', + }) + class TestComponent { + testField = 'default'; + } + + @Component({ + selector: 'test-cmp', + template: '...', + }) + class MockTestComponent { + testField = 'overridden'; + } + + @NgModule({ + declarations: [TestComponent], + exports: [TestComponent], + }) + class TestModule { + } + + @Component({ + standalone: true, + selector: 'app-root', + template: ``, + imports: [TestModule], + }) + class AppComponent { + @ViewChild('testCmpCtrl', {static: true}) testCmpCtrl!: TestComponent; + } + + it('should allow declarations and exports overrides on an imported NgModule', () => { + // replace TestComponent with MockTestComponent + TestBed.overrideModule(TestModule, { + remove: {declarations: [TestComponent], exports: [TestComponent]}, + add: {declarations: [MockTestComponent], exports: [MockTestComponent]} + }); + const fixture = TestBed.createComponent(AppComponent); + fixture.detectChanges(); + + const app = fixture.componentInstance; + expect(app.testCmpCtrl.testField).toBe('overridden'); + }); + }); +}); + describe('TestBed', () => { beforeEach(() => { getTestBed().resetTestingModule(); diff --git a/packages/core/testing/src/r3_test_bed.ts b/packages/core/testing/src/r3_test_bed.ts index 77176c2146b..9d8daa16a5f 100644 --- a/packages/core/testing/src/r3_test_bed.ts +++ b/packages/core/testing/src/r3_test_bed.ts @@ -418,8 +418,7 @@ export class TestBedRender3 implements TestBed { const componentDef = (type as any).ɵcmp; if (!componentDef) { - throw new Error( - `It looks like '${stringify(type)}' has not been IVY compiled - it has no 'ɵcmp' field`); + throw new Error(`It looks like '${stringify(type)}' has not been compiled.`); } // TODO: Don't cast as `InjectionToken`, proper type is boolean[] diff --git a/packages/core/testing/src/r3_test_bed_compiler.ts b/packages/core/testing/src/r3_test_bed_compiler.ts index 624aaa53693..1f5dcce7685 100644 --- a/packages/core/testing/src/r3_test_bed_compiler.ts +++ b/packages/core/testing/src/r3_test_bed_compiler.ts @@ -10,6 +10,7 @@ import {ResourceLoader} from '@angular/compiler'; import {ApplicationInitStatus, Compiler, COMPILER_OPTIONS, Component, Directive, Injector, InjectorType, LOCALE_ID, ModuleWithComponentFactories, ModuleWithProviders, NgModule, NgModuleFactory, NgZone, Pipe, PlatformRef, Provider, resolveForwardRef, Type, ɵcompileComponent as compileComponent, ɵcompileDirective as compileDirective, ɵcompileNgModuleDefs as compileNgModuleDefs, ɵcompilePipe as compilePipe, ɵDEFAULT_LOCALE_ID as DEFAULT_LOCALE_ID, ɵDirectiveDef as DirectiveDef, ɵgetInjectableDef as getInjectableDef, ɵNG_COMP_DEF as NG_COMP_DEF, ɵNG_DIR_DEF as NG_DIR_DEF, ɵNG_INJ_DEF as NG_INJ_DEF, ɵNG_MOD_DEF as NG_MOD_DEF, ɵNG_PIPE_DEF as NG_PIPE_DEF, ɵNgModuleFactory as R3NgModuleFactory, ɵNgModuleTransitiveScopes as NgModuleTransitiveScopes, ɵNgModuleType as NgModuleType, ɵpatchComponentDefWithScope as patchComponentDefWithScope, ɵRender3ComponentFactory as ComponentFactory, ɵRender3NgModuleRef as NgModuleRef, ɵsetLocaleId as setLocaleId, ɵtransitiveScopesFor as transitiveScopesFor, ɵɵInjectableDeclaration as InjectableDeclaration} from '@angular/core'; import {clearResolutionOfComponentResourcesQueue, isComponentDefPendingResolution, resolveComponentResources, restoreComponentResolutionQueue} from '../../src/metadata/resource_loading'; +import {ComponentDef, ComponentType} from '../../src/render3'; import {MetadataOverride} from './metadata_override'; import {ComponentResolver, DirectiveResolver, NgModuleResolver, PipeResolver, Resolver} from './resolvers'; @@ -424,8 +425,24 @@ export class R3TestBedCompiler { } this.moduleProvidersOverridden.add(moduleType); + // NOTE: the line below triggers JIT compilation of the module injector, + // which also invokes verification of the NgModule semantics, which produces + // detailed error messages. The fact that the code relies on this line being + // present here is suspicious and should be refactored in a way that the line + // below can be moved (for ex. after an early exit check below). const injectorDef: any = (moduleType as any)[NG_INJ_DEF]; - if (this.providerOverridesByToken.size > 0) { + + // No provider overrides, exit early. + if (this.providerOverridesByToken.size === 0) return; + + if (isStandaloneComponent(moduleType)) { + // Visit all component dependencies and override providers there. + const def = getComponentDef(moduleType); + const dependencies = maybeUnwrapFn(def.dependencies ?? []); + for (const dependency of dependencies) { + this.applyProviderOverridesToModule(dependency); + } + } else { const providers = [ ...injectorDef.providers, ...(this.providerOverridesByModule.get(moduleType as InjectorType) || []) @@ -482,7 +499,7 @@ export class R3TestBedCompiler { compileNgModuleDefs(ngModule as NgModuleType, metadata); } - private queueType(type: Type, moduleType: Type|TestingModuleOverride): void { + private queueType(type: Type, moduleType: Type|TestingModuleOverride|null): void { const component = this.resolvers.component.resolve(type); if (component) { // Check whether a give Type has respective NG def (ɵcmp) and compile if def is @@ -508,8 +525,11 @@ export class R3TestBedCompiler { // real module, which was imported. This pattern is understood to mean that the component // should use its original scope, but that the testing module should also contain the // component in its scope. - if (!this.componentToModuleScope.has(type) || - this.componentToModuleScope.get(type) === TestingModuleOverride.DECLARATION) { + // + // Note: standalone components have no associated NgModule, so the `moduleType` can be `null`. + if (moduleType !== null && + (!this.componentToModuleScope.has(type) || + this.componentToModuleScope.get(type) === TestingModuleOverride.DECLARATION)) { this.componentToModuleScope.set(type, moduleType); } return; @@ -553,6 +573,10 @@ export class R3TestBedCompiler { queueTypesFromModulesArrayRecur(maybeUnwrapFn(def.exports)); } else if (isModuleWithProviders(value)) { queueTypesFromModulesArrayRecur([value.ngModule]); + } else if (isStandaloneComponent(value)) { + this.queueType(value, null); + const def = getComponentDef(value); + queueTypesFromModulesArrayRecur(maybeUnwrapFn(def.dependencies ?? [])); } } }; @@ -790,6 +814,17 @@ function initResolvers(): Resolvers { }; } +function isStandaloneComponent(value: Type): value is ComponentType { + const def = getComponentDef(value); + return !!def?.standalone; +} + +function getComponentDef(value: ComponentType): ComponentDef; +function getComponentDef(value: Type): ComponentDef|null; +function getComponentDef(value: Type): ComponentDef|null { + return (value as any).ɵcmp ?? null; +} + function hasNgModuleDef(value: Type): value is NgModuleType { return value.hasOwnProperty('ɵmod'); }