feat(ivy): TestBed support for reusing non-exported components (#30578)
This is a new feature of the Ivy TestBed. A common user pattern is to test one component with another. This is commonly done by creating a `TestFixture` component which exercises the main component under test. This pattern is more difficult if the component under test is declared in an NgModule but not exported. In this case, overriding the module is necessary. In g3 (and View Engine), it's possible to use an NgSummary to override the recompilation of a component, and depend on its AOT compiled factory. The way this is implemented, however, specifying a summary for a module effectively overrides much of the TestBed's other behavior. For example, the following is legal: ```typescript TestBed.configureTestingModule({ declarations: [FooCmp, TestFixture], imports: [FooModule], aotSummaries: [FooModuleNgSummary], }); ``` Here, `FooCmp` is declared in both the testing module and in the imported `FooModule`. However, because the summary is provided, `FooCmp` is not compiled within the context of the testing module, but _is_ made available for `TestFixture` to use, even if it wasn't originally exported from `FooModule`. This pattern breaks in Ivy - because summaries are a no-op, this amounts to a true double declaration of `FooCmp` which raises an error. Fixing this in user code is possible, but is difficult to do in an automated or backwards compatible way. An alternative solution is implemented in this PR. This PR attempts to capture the user intent of the following previously unsupported configuration: ```typescript TestBed.configureTestingModule({ declarations: [FooCmp, TestFixture], imports: [FooModule], }); ``` Note that this is the same as the configuration above in Ivy, as the `aotSummaries` value provided has no effect. The user intent here is interpreted as follows: 1) `FooCmp` is a pre-existing component that's being used in the test (via import of `FooModule`). It may or may not be exported by this module. 2) `FooCmp` should be part of the testing module's scope. That is, it should be visible to `TestFixture`. This is because it's listed in `declarations`. This feature effectively makes the `TestBed` testing module special. It's able to declare components without compiling them, if they're already compiled (or configured to be compiled) in the imports. And crucially, the behavior matches the first example with the summary, making Ivy backwards compatible with View Engine for tests that use summaries. PR Close #30578
This commit is contained in:
parent
d5f96a887d
commit
deb77bd3df
|
@ -94,8 +94,13 @@ export function compileNgModule(moduleType: Type<any>, ngModule: NgModule = {}):
|
|||
|
||||
/**
|
||||
* Compiles and adds the `ngModuleDef` and `ngInjectorDef` properties to the module class.
|
||||
*
|
||||
* It's possible to compile a module via this API which will allow duplicate declarations in its
|
||||
* root.
|
||||
*/
|
||||
export function compileNgModuleDefs(moduleType: NgModuleType, ngModule: NgModule): void {
|
||||
export function compileNgModuleDefs(
|
||||
moduleType: NgModuleType, ngModule: NgModule,
|
||||
allowDuplicateDeclarationsInRoot: boolean = false): void {
|
||||
ngDevMode && assertDefined(moduleType, 'Required value moduleType');
|
||||
ngDevMode && assertDefined(ngModule, 'Required value ngModule');
|
||||
const declarations: Type<any>[] = flatten(ngModule.declarations || EMPTY_ARRAY);
|
||||
|
@ -129,7 +134,8 @@ export function compileNgModuleDefs(moduleType: NgModuleType, ngModule: NgModule
|
|||
Object.defineProperty(moduleType, NG_INJECTOR_DEF, {
|
||||
get: () => {
|
||||
if (ngInjectorDef === null) {
|
||||
ngDevMode && verifySemanticsOfNgModuleDef(moduleType as any as NgModuleType);
|
||||
ngDevMode && verifySemanticsOfNgModuleDef(
|
||||
moduleType as any as NgModuleType, allowDuplicateDeclarationsInRoot);
|
||||
const meta: R3InjectorMetadataFacade = {
|
||||
name: moduleType.name,
|
||||
type: moduleType,
|
||||
|
@ -150,7 +156,8 @@ export function compileNgModuleDefs(moduleType: NgModuleType, ngModule: NgModule
|
|||
});
|
||||
}
|
||||
|
||||
function verifySemanticsOfNgModuleDef(moduleType: NgModuleType): void {
|
||||
function verifySemanticsOfNgModuleDef(
|
||||
moduleType: NgModuleType, allowDuplicateDeclarationsInRoot: boolean): void {
|
||||
if (verifiedNgModule.get(moduleType)) return;
|
||||
verifiedNgModule.set(moduleType, true);
|
||||
moduleType = resolveForwardRef(moduleType);
|
||||
|
@ -158,7 +165,9 @@ function verifySemanticsOfNgModuleDef(moduleType: NgModuleType): void {
|
|||
const errors: string[] = [];
|
||||
const declarations = maybeUnwrapFn(ngModuleDef.declarations);
|
||||
const imports = maybeUnwrapFn(ngModuleDef.imports);
|
||||
flatten(imports).map(unwrapModuleWithProvidersImports).forEach(verifySemanticsOfNgModuleDef);
|
||||
flatten(imports)
|
||||
.map(unwrapModuleWithProvidersImports)
|
||||
.forEach(mod => verifySemanticsOfNgModuleDef(mod, false));
|
||||
const exports = maybeUnwrapFn(ngModuleDef.exports);
|
||||
declarations.forEach(verifyDeclarationsHaveDefinitions);
|
||||
const combinedDeclarations: Type<any>[] = [
|
||||
|
@ -166,7 +175,7 @@ function verifySemanticsOfNgModuleDef(moduleType: NgModuleType): void {
|
|||
...flatten(imports.map(computeCombinedExports)).map(resolveForwardRef),
|
||||
];
|
||||
exports.forEach(verifyExportsAreDeclaredOrReExported);
|
||||
declarations.forEach(verifyDeclarationIsUnique);
|
||||
declarations.forEach(decl => verifyDeclarationIsUnique(decl, allowDuplicateDeclarationsInRoot));
|
||||
declarations.forEach(verifyComponentEntryComponentsIsPartOfNgModule);
|
||||
|
||||
const ngModule = getAnnotation<NgModule>(moduleType, 'NgModule');
|
||||
|
@ -174,7 +183,7 @@ function verifySemanticsOfNgModuleDef(moduleType: NgModuleType): void {
|
|||
ngModule.imports &&
|
||||
flatten(ngModule.imports)
|
||||
.map(unwrapModuleWithProvidersImports)
|
||||
.forEach(verifySemanticsOfNgModuleDef);
|
||||
.forEach(mod => verifySemanticsOfNgModuleDef(mod, false));
|
||||
ngModule.bootstrap && ngModule.bootstrap.forEach(verifyCorrectBootstrapType);
|
||||
ngModule.bootstrap && ngModule.bootstrap.forEach(verifyComponentIsPartOfNgModule);
|
||||
ngModule.entryComponents && ngModule.entryComponents.forEach(verifyComponentIsPartOfNgModule);
|
||||
|
@ -209,15 +218,17 @@ function verifySemanticsOfNgModuleDef(moduleType: NgModuleType): void {
|
|||
}
|
||||
}
|
||||
|
||||
function verifyDeclarationIsUnique(type: Type<any>) {
|
||||
function verifyDeclarationIsUnique(type: Type<any>, suppressErrors: boolean) {
|
||||
type = resolveForwardRef(type);
|
||||
const existingModule = ownerNgModule.get(type);
|
||||
if (existingModule && existingModule !== moduleType) {
|
||||
const modules = [existingModule, moduleType].map(stringifyForError).sort();
|
||||
errors.push(
|
||||
`Type ${stringifyForError(type)} is part of the declarations of 2 modules: ${modules[0]} and ${modules[1]}! ` +
|
||||
`Please consider moving ${stringifyForError(type)} to a higher module that imports ${modules[0]} and ${modules[1]}. ` +
|
||||
`You can also create a new NgModule that exports and includes ${stringifyForError(type)} then import that NgModule in ${modules[0]} and ${modules[1]}.`);
|
||||
if (!suppressErrors) {
|
||||
const modules = [existingModule, moduleType].map(stringifyForError).sort();
|
||||
errors.push(
|
||||
`Type ${stringifyForError(type)} is part of the declarations of 2 modules: ${modules[0]} and ${modules[1]}! ` +
|
||||
`Please consider moving ${stringifyForError(type)} to a higher module that imports ${modules[0]} and ${modules[1]}. ` +
|
||||
`You can also create a new NgModule that exports and includes ${stringifyForError(type)} then import that NgModule in ${modules[0]} and ${modules[1]}.`);
|
||||
}
|
||||
} else {
|
||||
// Mark type as having owner.
|
||||
ownerNgModule.set(type, moduleType);
|
||||
|
@ -312,7 +323,7 @@ function computeCombinedExports(type: Type<any>): Type<any>[] {
|
|||
return [...flatten(maybeUnwrapFn(ngModuleDef.exports).map((type) => {
|
||||
const ngModuleDef = getNgModuleDef(type);
|
||||
if (ngModuleDef) {
|
||||
verifySemanticsOfNgModuleDef(type as any as NgModuleType);
|
||||
verifySemanticsOfNgModuleDef(type as any as NgModuleType, false);
|
||||
return computeCombinedExports(type);
|
||||
} else {
|
||||
return type;
|
||||
|
|
|
@ -6,7 +6,7 @@
|
|||
* found in the LICENSE file at https://angular.io/license
|
||||
*/
|
||||
|
||||
import {Component, Directive, ErrorHandler, Inject, Injectable, InjectionToken, Input, NgModule, Optional, Pipe, ɵsetClassMetadata as setClassMetadata, ɵɵdefineComponent as defineComponent, ɵɵtext as text} from '@angular/core';
|
||||
import {Component, Directive, ErrorHandler, Inject, Injectable, InjectionToken, Input, NgModule, Optional, Pipe, ɵsetClassMetadata as setClassMetadata, ɵɵdefineComponent as defineComponent, ɵɵdefineNgModule as defineNgModule, ɵɵtext as text} from '@angular/core';
|
||||
import {TestBed, getTestBed} from '@angular/core/testing/src/test_bed';
|
||||
import {By} from '@angular/platform-browser';
|
||||
import {expect} from '@angular/platform-browser/testing/src/matchers';
|
||||
|
@ -371,6 +371,62 @@ describe('TestBed', () => {
|
|||
}).toThrowError();
|
||||
});
|
||||
|
||||
onlyInIvy('TestBed new feature to allow declaration and import of component')
|
||||
.it('should allow both the declaration and import of a component into the testing module',
|
||||
() => {
|
||||
// This test validates that a component (Outer) which is both declared and imported
|
||||
// (via its module) in the testing module behaves correctly. That is:
|
||||
//
|
||||
// 1) the component should be compiled in the scope of its original module.
|
||||
//
|
||||
// This condition is tested by having the component (Outer) use another component
|
||||
// (Inner) within its template. Thus, if it's compiled in the correct scope then the
|
||||
// text 'Inner' from the template of (Inner) should appear in the result.
|
||||
//
|
||||
// 2) the component should be available in the TestingModule scope.
|
||||
//
|
||||
// This condition is tested by attempting to use the component (Outer) inside a test
|
||||
// fixture component (Fixture) which is declared in the testing module only.
|
||||
|
||||
@Component({
|
||||
selector: 'inner',
|
||||
template: 'Inner',
|
||||
})
|
||||
class Inner {
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'outer',
|
||||
template: '<inner></inner>',
|
||||
})
|
||||
class Outer {
|
||||
}
|
||||
|
||||
@NgModule({
|
||||
declarations: [Inner, Outer],
|
||||
})
|
||||
class Module {
|
||||
}
|
||||
|
||||
@Component({
|
||||
template: '<outer></outer>',
|
||||
selector: 'fixture',
|
||||
})
|
||||
class Fixture {
|
||||
}
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
declarations: [Outer, Fixture],
|
||||
imports: [Module],
|
||||
});
|
||||
|
||||
const fixture = TestBed.createComponent(Fixture);
|
||||
// The Outer component should have its template stamped out, and that template should
|
||||
// include a correct instance of the Inner component with the 'Inner' text from its
|
||||
// template.
|
||||
expect(fixture.nativeElement.innerHTML).toEqual('<outer><inner>Inner</inner></outer>');
|
||||
});
|
||||
|
||||
onlyInIvy('TestBed should handle AOT pre-compiled Components')
|
||||
.describe('AOT pre-compiled components', () => {
|
||||
/**
|
||||
|
@ -431,6 +487,42 @@ describe('TestBed', () => {
|
|||
const fixture = TestBed.createComponent(SomeComponent);
|
||||
expect(fixture.nativeElement.innerHTML).toBe('');
|
||||
});
|
||||
|
||||
it('should allow component in both in declarations and imports', () => {
|
||||
const SomeComponent = getAOTCompiledComponent();
|
||||
|
||||
// This is an AOT compiled module which declares (but does not export) SomeComponent.
|
||||
class ModuleClass {
|
||||
static ngModuleDef = defineNgModule({
|
||||
type: ModuleClass,
|
||||
declarations: [SomeComponent],
|
||||
});
|
||||
}
|
||||
|
||||
@Component({
|
||||
template: '<comp></comp>',
|
||||
|
||||
selector: 'fixture',
|
||||
})
|
||||
class TestFixture {
|
||||
}
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
// Here, SomeComponent is both declared, and then the module which declares it is
|
||||
// also imported. This used to be a duplicate declaration error, but is now interpreted
|
||||
// to mean:
|
||||
// 1) Compile (or reuse) SomeComponent in the context of its original NgModule
|
||||
// 2) Make SomeComponent available in the scope of the testing module, even if it wasn't
|
||||
// originally exported from its NgModule.
|
||||
//
|
||||
// This allows TestFixture to use SomeComponent, which is asserted below.
|
||||
declarations: [SomeComponent, TestFixture],
|
||||
imports: [ModuleClass],
|
||||
});
|
||||
const fixture = TestBed.createComponent(TestFixture);
|
||||
// The regex avoids any issues with styling attributes.
|
||||
expect(fixture.nativeElement.innerHTML).toMatch(/<comp[^>]*>Some template<\/comp>/);
|
||||
});
|
||||
});
|
||||
|
||||
onlyInIvy('patched ng defs should be removed after resetting TestingModule')
|
||||
|
|
|
@ -15,8 +15,15 @@ import {MetadataOverride} from './metadata_override';
|
|||
import {ComponentResolver, DirectiveResolver, NgModuleResolver, PipeResolver, Resolver} from './resolvers';
|
||||
import {TestModuleMetadata} from './test_bed_common';
|
||||
|
||||
const TESTING_MODULE = 'TestingModule';
|
||||
type TESTING_MODULE = typeof TESTING_MODULE;
|
||||
enum TestingModuleOverride {
|
||||
DECLARATION,
|
||||
OVERRIDE_TEMPLATE,
|
||||
}
|
||||
|
||||
function isTestingModuleOverride(value: unknown): value is TestingModuleOverride {
|
||||
return value === TestingModuleOverride.DECLARATION ||
|
||||
value === TestingModuleOverride.OVERRIDE_TEMPLATE;
|
||||
}
|
||||
|
||||
// Resolvers for Angular decorators
|
||||
type Resolvers = {
|
||||
|
@ -56,7 +63,7 @@ export class R3TestBedCompiler {
|
|||
|
||||
private resolvers: Resolvers = initResolvers();
|
||||
|
||||
private componentToModuleScope = new Map<Type<any>, Type<any>|TESTING_MODULE>();
|
||||
private componentToModuleScope = new Map<Type<any>, Type<any>|TestingModuleOverride>();
|
||||
|
||||
// Map that keeps initial version of component/directive/pipe defs in case
|
||||
// we compile a Type again, thus overriding respective static fields. This is
|
||||
|
@ -92,7 +99,7 @@ export class R3TestBedCompiler {
|
|||
configureTestingModule(moduleDef: TestModuleMetadata): void {
|
||||
// Enqueue any compilation tasks for the directly declared component.
|
||||
if (moduleDef.declarations !== undefined) {
|
||||
this.queueTypeArray(moduleDef.declarations, TESTING_MODULE);
|
||||
this.queueTypeArray(moduleDef.declarations, TestingModuleOverride.DECLARATION);
|
||||
this.declarations.push(...moduleDef.declarations);
|
||||
}
|
||||
|
||||
|
@ -188,7 +195,7 @@ export class R3TestBedCompiler {
|
|||
}
|
||||
|
||||
// Set the component's scope to be the testing module.
|
||||
this.componentToModuleScope.set(type, TESTING_MODULE);
|
||||
this.componentToModuleScope.set(type, TestingModuleOverride.OVERRIDE_TEMPLATE);
|
||||
}
|
||||
|
||||
async compileComponents(): Promise<void> {
|
||||
|
@ -306,14 +313,15 @@ export class R3TestBedCompiler {
|
|||
}
|
||||
|
||||
private applyTransitiveScopes(): void {
|
||||
const moduleToScope = new Map<Type<any>|TESTING_MODULE, NgModuleTransitiveScopes>();
|
||||
const getScopeOfModule = (moduleType: Type<any>| TESTING_MODULE): NgModuleTransitiveScopes => {
|
||||
if (!moduleToScope.has(moduleType)) {
|
||||
const realType = moduleType === TESTING_MODULE ? this.testModuleType : moduleType;
|
||||
moduleToScope.set(moduleType, transitiveScopesFor(realType));
|
||||
}
|
||||
return moduleToScope.get(moduleType) !;
|
||||
};
|
||||
const moduleToScope = new Map<Type<any>|TestingModuleOverride, NgModuleTransitiveScopes>();
|
||||
const getScopeOfModule =
|
||||
(moduleType: Type<any>| TestingModuleOverride): NgModuleTransitiveScopes => {
|
||||
if (!moduleToScope.has(moduleType)) {
|
||||
const realType = isTestingModuleOverride(moduleType) ? this.testModuleType : moduleType;
|
||||
moduleToScope.set(moduleType, transitiveScopesFor(realType));
|
||||
}
|
||||
return moduleToScope.get(moduleType) !;
|
||||
};
|
||||
|
||||
this.componentToModuleScope.forEach((moduleType, componentType) => {
|
||||
const moduleScope = getScopeOfModule(moduleType);
|
||||
|
@ -370,7 +378,7 @@ export class R3TestBedCompiler {
|
|||
this.existingComponentStyles.clear();
|
||||
}
|
||||
|
||||
private queueTypeArray(arr: any[], moduleType: Type<any>|TESTING_MODULE): void {
|
||||
private queueTypeArray(arr: any[], moduleType: Type<any>|TestingModuleOverride): void {
|
||||
for (const value of arr) {
|
||||
if (Array.isArray(value)) {
|
||||
this.queueTypeArray(value, moduleType);
|
||||
|
@ -392,7 +400,7 @@ export class R3TestBedCompiler {
|
|||
compileNgModuleDefs(ngModule as NgModuleType<any>, metadata);
|
||||
}
|
||||
|
||||
private queueType(type: Type<any>, moduleType: Type<any>|TESTING_MODULE): void {
|
||||
private queueType(type: Type<any>, moduleType: Type<any>|TestingModuleOverride): void {
|
||||
const component = this.resolvers.component.resolve(type);
|
||||
if (component) {
|
||||
// Check whether a give Type has respective NG def (ngComponentDef) and compile if def is
|
||||
|
@ -404,9 +412,22 @@ export class R3TestBedCompiler {
|
|||
this.seenComponents.add(type);
|
||||
|
||||
// Keep track of the module which declares this component, so later the component's scope
|
||||
// can be set correctly. Only record this the first time, because it might be overridden by
|
||||
// overrideTemplateUsingTestingModule.
|
||||
if (!this.componentToModuleScope.has(type)) {
|
||||
// can be set correctly. If the component has already been recorded here, then one of several
|
||||
// cases is true:
|
||||
// * the module containing the component was imported multiple times (common).
|
||||
// * the component is declared in multiple modules (which is an error).
|
||||
// * the component was in 'declarations' of the testing module, and also in an imported module
|
||||
// in which case the module scope will be TestingModuleOverride.DECLARATION.
|
||||
// * overrideTemplateUsingTestingModule was called for the component in which case the module
|
||||
// scope will be TestingModuleOverride.OVERRIDE_TEMPLATE.
|
||||
//
|
||||
// If the component was previously in the testing module's 'declarations' (meaning the
|
||||
// current value is TestingModuleOverride.DECLARATION), then `moduleType` is the component's
|
||||
// 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) {
|
||||
this.componentToModuleScope.set(type, moduleType);
|
||||
}
|
||||
return;
|
||||
|
@ -525,7 +546,7 @@ export class R3TestBedCompiler {
|
|||
imports,
|
||||
schemas: this.schemas,
|
||||
providers,
|
||||
});
|
||||
}, /* allowDuplicateDeclarationsInRoot */ true);
|
||||
// clang-format on
|
||||
|
||||
this.applyProviderOverridesToModule(this.testModuleType);
|
||||
|
|
Loading…
Reference in New Issue