diff --git a/adev/src/app/app.component.spec.ts b/adev/src/app/app.component.spec.ts index 1b5360966333..1b698ab66925 100644 --- a/adev/src/app/app.component.spec.ts +++ b/adev/src/app/app.component.spec.ts @@ -6,20 +6,20 @@ * found in the LICENSE file at https://angular.dev/license */ +import {provideHttpClient} from '@angular/common/http'; +import {provideHttpClientTesting} from '@angular/common/http/testing'; import {TestBed} from '@angular/core/testing'; -import {AppComponent} from './app.component'; +import {Search, WINDOW} from '@angular/docs'; import {provideRouter, withComponentInputBinding} from '@angular/router'; +import {AppComponent} from './app.component'; import {routes} from './routing/routes'; -import {Search, WINDOW} from '@angular/docs'; -import {provideHttpClient} from '@angular/common/http'; -import {provideHttpClientTesting} from '@angular/common/http/testing'; describe('AppComponent', () => { const fakeSearch = {}; const fakeWindow = {location: {hostname: 'angular.dev'}}; - it('should create the app', async () => { - await TestBed.configureTestingModule({ + it('should create the app', () => { + TestBed.configureTestingModule({ imports: [AppComponent], providers: [ provideHttpClient(), @@ -34,7 +34,7 @@ describe('AppComponent', () => { useValue: fakeSearch, }, ], - }).compileComponents(); + }); const fixture = TestBed.createComponent(AppComponent); const app = fixture.componentInstance; diff --git a/dev-app/src/app/app.spec.ts b/dev-app/src/app/app.spec.ts index 6690af8000b3..7a6dc876d1d7 100644 --- a/dev-app/src/app/app.spec.ts +++ b/dev-app/src/app/app.spec.ts @@ -5,7 +5,7 @@ describe('App', () => { beforeEach(async () => { await TestBed.configureTestingModule({ imports: [App], - }).compileComponents(); + }); }); it('should create the app', () => { diff --git a/devtools/projects/ng-devtools/src/lib/devtools-tabs/router-tree/router-tree.component.spec.ts b/devtools/projects/ng-devtools/src/lib/devtools-tabs/router-tree/router-tree.component.spec.ts index b3995cad56d4..a566c70bdece 100644 --- a/devtools/projects/ng-devtools/src/lib/devtools-tabs/router-tree/router-tree.component.spec.ts +++ b/devtools/projects/ng-devtools/src/lib/devtools-tabs/router-tree/router-tree.component.spec.ts @@ -8,12 +8,12 @@ import {ComponentFixture, TestBed} from '@angular/core/testing'; -import {RouterTreeComponent} from './router-tree.component'; -import SpyObj = jasmine.SpyObj; -import {FrameManager} from '../../application-services/frame_manager'; +import {provideZoneChangeDetection} from '@angular/core'; import {Events, MessageBus} from '../../../../../protocol'; import {ApplicationOperations} from '../../application-operations'; -import {provideZoneChangeDetection} from '@angular/core'; +import {FrameManager} from '../../application-services/frame_manager'; +import {RouterTreeComponent} from './router-tree.component'; +import SpyObj = jasmine.SpyObj; describe('RouterTreeComponent', () => { let messageBus: MessageBus; @@ -39,7 +39,7 @@ describe('RouterTreeComponent', () => { {provide: MessageBus, useValue: messageBus}, {provide: FrameManager, useValue: frameManager}, ], - }).compileComponents(); + }); fixture = TestBed.createComponent(RouterTreeComponent); fixture.componentRef.setInput('routes', [ diff --git a/devtools/projects/shell-browser/src/app/app.component.spec.ts b/devtools/projects/shell-browser/src/app/app.component.spec.ts index bc8ebc4481ca..0c1acc0289de 100644 --- a/devtools/projects/shell-browser/src/app/app.component.spec.ts +++ b/devtools/projects/shell-browser/src/app/app.component.spec.ts @@ -24,7 +24,7 @@ describe('AppComponent', () => { useClass: applicationOperationsSPy, }, ], - }).compileComponents(); + }); })); it('should create the app', () => { diff --git a/packages/core/src/render3/metadata.ts b/packages/core/src/render3/metadata.ts index 3203b168fd81..f6bcb73db3e0 100644 --- a/packages/core/src/render3/metadata.ts +++ b/packages/core/src/render3/metadata.ts @@ -22,6 +22,8 @@ interface TypeWithMetadata extends Type { */ const ASYNC_COMPONENT_METADATA_FN = '__ngAsyncComponentMetadataFn__'; +const ASYNC_METADATA_LOADED = '__ngAsyncMetadataLoaded__'; + /** * If a given component has unresolved async metadata - returns a reference * to a function that applies component metadata after resolving defer-loadable @@ -31,9 +33,22 @@ export function getAsyncClassMetadataFn( type: Type, ): (() => Promise>>) | null { const componentClass = type as any; // cast to `any`, so that we can read a monkey-patched field + if (componentClass[ASYNC_COMPONENT_METADATA_FN] === ASYNC_METADATA_LOADED) { + return null; + } + return componentClass[ASYNC_COMPONENT_METADATA_FN] ?? null; } +/** + * Checks if a given component has async metadata. + * Independent of whether the metadata is already loaded or not. + */ +export function hasAsyncClassMetadata(type: Type): boolean { + const componentClass = type as any; // cast to `any`, so that we can monkey-patch it + return !!componentClass[ASYNC_COMPONENT_METADATA_FN]; +} + /** * Handles the process of applying metadata info to a component class in case * component template has defer blocks (thus some dependencies became deferrable). @@ -53,7 +68,7 @@ export function setClassMetadataAsync( metadataSetterFn(...dependencies); // Metadata is now set, reset field value to indicate that this component // can by used/compiled synchronously. - componentClass[ASYNC_COMPONENT_METADATA_FN] = null; + componentClass[ASYNC_COMPONENT_METADATA_FN] = ASYNC_METADATA_LOADED; return dependencies; }); diff --git a/packages/core/test/test_bed_spec.ts b/packages/core/test/test_bed_spec.ts index daf24fc341ec..7d4703fa1f4a 100644 --- a/packages/core/test/test_bed_spec.ts +++ b/packages/core/test/test_bed_spec.ts @@ -1701,38 +1701,141 @@ describe('TestBed', () => { }, }); } - setClassMetadataAsync( - ComponentClass, - function () { - const promises: Array>> = deferrableDependencies.map( - // Emulates a dynamic import, e.g. `import('./cmp-a').then(m => m.CmpA)` - (dep) => new Promise((resolve) => setTimeout(() => resolve(dep))), - ); - return promises; - }, - function (...deferrableSymbols) { - setClassMetadata( - ComponentClass, - [ - { - type: Component, - args: [ - { - selector, - imports: [...dependencies, ...deferrableSymbols], - template: `
root cmp!
`, - }, - ], - }, - ], - null, - null, - ); - }, - ); + if (dependencies.length || deferrableDependencies.length) { + setClassMetadataAsync( + ComponentClass, + function () { + const promises: Array>> = deferrableDependencies.map( + // Emulates a dynamic import, e.g. `import('./cmp-a').then(m => m.CmpA)` + (dep) => new Promise((resolve) => setTimeout(() => resolve(dep))), + ); + return promises; + }, + function (...deferrableSymbols) { + setClassMetadata( + ComponentClass, + [ + { + type: Component, + args: [ + { + selector, + imports: [...dependencies, ...deferrableSymbols], + template: `
root cmp!
`, + }, + ], + }, + ], + null, + null, + ); + }, + ); + } return ComponentClass; }; + it('should not require compilerComponents if the component with a defer-block is not overridden', async () => { + const DeferredComponent = getAOTCompiledComponent('deferred'); + const RootAotComponent = getAOTCompiledComponent('root', [], [DeferredComponent]); + + TestBed.configureTestingModule({imports: [RootAotComponent]}); + + // If we had overriden the component, we would need to compile it + // but since we didn't, we can create the component synchronously + const fixture = TestBed.createComponent(RootAotComponent); + fixture.detectChanges(); + + expect(fixture.nativeElement.textContent).toBe('root cmp!'); + + // Here we're just confirming that the same component would throw on override. + TestBed.resetTestingModule() + .configureTestingModule({imports: [RootAotComponent]}) + .overrideComponent(RootAotComponent, { + set: {template: `Override of a root template!`}, + }); + expect(() => TestBed.createComponent(RootAotComponent)).toThrow(); + }); + + it('should not throw an error in AOT component is overriden but has no async metadata', () => { + const RootAotComponent = getAOTCompiledComponent('root', [], []); + TestBed.configureTestingModule({imports: [RootAotComponent]}); + + TestBed.overrideComponent(RootAotComponent, { + set: {template: `Override of a root template! `}, + }); + + expect(() => TestBed.createComponent(RootAotComponent)).not.toThrowError(); + }); + + it('should throw an error if a deferred component is not compiled', () => { + @Component({ + selector: 'cmp-a', + template: 'CmpA!', + }) + class CmpA {} + + const NestedAotComponent = getAOTCompiledComponent('nested-cmp', [], [CmpA]); + const RootAotComponent = getAOTCompiledComponent('root', [], [NestedAotComponent]); + + TestBed.configureTestingModule({imports: [RootAotComponent]}); + + TestBed.overrideComponent(RootAotComponent, { + set: {template: `Override of a root template! `}, + }); + TestBed.overrideComponent(NestedAotComponent, { + set: {template: `Override of a nested template! `}, + }); + + // We did override but not compile, therefore we expect to throw on createComponent + expect(() => TestBed.createComponent(RootAotComponent)).toThrowError( + `Component 'ComponentClass' has unresolved metadata. Please call \`await TestBed.compileComponents()\` before running this test.`, + ); + }); + + it('should not throw if component is created after override+reset', async () => { + @Component({ + selector: 'cmp-a', + template: 'CmpA!', + }) + class CmpA {} + + const NestedAotComponent = getAOTCompiledComponent('nested-cmp', [], [CmpA]); + const RootAotComponent = getAOTCompiledComponent('root', [], [NestedAotComponent]); + + TestBed.configureTestingModule({imports: [RootAotComponent]}); + + TestBed.overrideComponent(RootAotComponent, { + set: {template: `Override of a root template! `}, + }); + TestBed.overrideComponent(NestedAotComponent, { + set: {template: `Override of a nested template! `}, + }); + + // Not compiled yet, so we expect to throw + expect(() => TestBed.createComponent(RootAotComponent)).toThrowError(); + + await TestBed.compileComponents(); + + // We're compiled now, so we can create the component + const fixture = TestBed.createComponent(RootAotComponent); + fixture.detectChanges(); + + expect(fixture.nativeElement.textContent).toBe( + 'Override of a root template! Override of a nested template! CmpA!', + ); + + // We reset the override + TestBed.resetTestingModule(); + TestBed.configureTestingModule({imports: [RootAotComponent]}); + + // We're back to the nominal behavior + const fixture2 = TestBed.createComponent(RootAotComponent); + fixture2.detectChanges(); + + expect(fixture2.nativeElement.textContent).toBe('root cmp!'); + }); + it('should handle async metadata on root and nested components', async () => { @Component({ selector: 'cmp-a', @@ -1752,7 +1855,7 @@ describe('TestBed', () => { set: {template: `Override of a nested template! `}, }); - // This is only required because the components are AOT compiled and thus include setClassMetadataAsync + // We need to compile the component because it has async metadata + overrides await TestBed.compileComponents(); const fixture = TestBed.createComponent(RootAotComponent); @@ -1839,7 +1942,7 @@ describe('TestBed', () => { TestBed.configureTestingModule({imports: [ParentCmp], providers: [COMMON_PROVIDERS]}); TestBed.overrideProvider(ImportantService, {useValue: {value: 'overridden'}}); - // This is only required because the component has setClassMetadataAsync + // We need to compile the component because it has async metadata + overrides await TestBed.compileComponents(); const fixture = TestBed.createComponent(ParentCmp); @@ -1852,8 +1955,9 @@ describe('TestBed', () => { }); it('should allow import overrides on components with async metadata', async () => { + const DeferredComponent = getAOTCompiledComponent('deferred', [], []); const NestedAotComponent = getAOTCompiledComponent('nested-cmp', [], []); - const RootAotComponent = getAOTCompiledComponent('root', [], []); + const RootAotComponent = getAOTCompiledComponent('root', [], [DeferredComponent]); TestBed.configureTestingModule({imports: [RootAotComponent]}); @@ -1865,7 +1969,7 @@ describe('TestBed', () => { }, }); - // This is only required because the components are AOT compiled and thus include setClassMetadataAsync + // We need to compile the component because it has async metadata + overrides await TestBed.compileComponents(); const fixture = TestBed.createComponent(RootAotComponent); @@ -1875,6 +1979,64 @@ describe('TestBed', () => { 'Override of a root template! nested-cmp cmp!', ); }); + + it('should throw when overriding template', async () => { + const DeferredComponent = getAOTCompiledComponent('deferred', [], []); + const RootAotComponent = getAOTCompiledComponent('root', [], [DeferredComponent]); + TestBed.overrideTemplate(RootAotComponent, 'foobar'); + + expect(() => TestBed.createComponent(RootAotComponent)).toThrowError( + /has unresolved metadata/, + ); + }); + + it('should throw if overriding template', async () => { + const DeferredComponent = getAOTCompiledComponent('deferred', [], []); + const RootAotComponent = getAOTCompiledComponent('root', [], [DeferredComponent]); + class Foo {} + + TestBed.overrideComponent(RootAotComponent, {set: {providers: [Foo]}}); + + expect(() => TestBed.createComponent(RootAotComponent)).toThrowError( + /has unresolved metadata/, + ); + }); + + describe('ensure AsyncMetadata loading is side-effect free', () => { + let Component: any; + let run = 1; + beforeEach(() => { + const DeferredComponent = getAOTCompiledComponent('deferred', [], []); + Component = getAOTCompiledComponent('root', [], [DeferredComponent]); + TestBed.overrideComponent(Component, {set: {template: 'bar'}}); + }); + + it('should throw if creating an overridden component', async () => { + if (run === 1) { + await TestBed.compileComponents(); + const fixture = TestBed.createComponent(Component); + expect(fixture.nativeElement.textContent).toBe('bar'); + } else { + // Component was compiled in the previous test + // but we still require compileComponents because of the override + expect(() => TestBed.createComponent(Component)).toThrowError(); + } + run++; + }); + + it('should throw if creating an overridden component (run 2)', async () => { + if (run === 1) { + await TestBed.compileComponents(); + const fixture = TestBed.createComponent(Component); + expect(fixture.nativeElement.textContent).toBe('bar'); + } else { + // Component was compiled in the previous test + // but we still require compileComponents because of the override + expect(() => TestBed.createComponent(Component)).toThrowError(); + } + run++; + }); + }); }); describe('AOT pre-compiled components', () => { diff --git a/packages/core/testing/src/resolvers.ts b/packages/core/testing/src/resolvers.ts index 52c98b083e1c..2e1890e60c28 100644 --- a/packages/core/testing/src/resolvers.ts +++ b/packages/core/testing/src/resolvers.ts @@ -11,8 +11,8 @@ import { Directive, NgModule, Pipe, - Type, ɵReflectionCapabilities as ReflectionCapabilities, + Type, } from '../../src/core'; import {MetadataOverride} from './metadata_override'; @@ -26,6 +26,8 @@ const reflection = new ReflectionCapabilities(); export interface Resolver { addOverride(type: Type, override: MetadataOverride): void; setOverrides(overrides: Array<[Type, MetadataOverride]>): void; + getOverrides(type: Type): MetadataOverride[] | null; + hasOverrides(type: Type): boolean; resolve(type: Type): T | null; } @@ -52,6 +54,13 @@ abstract class OverrideResolver implements Resolver { }); } + getOverrides(type: Type): MetadataOverride[] | null { + return this.overrides.get(type) || null; + } + + hasOverrides(type: Type): boolean { + return this.overrides.has(type); + } getAnnotation(type: Type): T | null { const annotations = reflection.annotations(type); // Try to find the nearest known Type annotation and make sure that this annotation is an diff --git a/packages/core/testing/src/test_bed.ts b/packages/core/testing/src/test_bed.ts index 8fcce31137a8..a841ad4013e5 100644 --- a/packages/core/testing/src/test_bed.ts +++ b/packages/core/testing/src/test_bed.ts @@ -21,8 +21,10 @@ import { EnvironmentInjector, ɵflushModuleScopingQueueAsMuchAsPossible as flushModuleScopingQueueAsMuchAsPossible, ɵgetAsyncClassMetadataFn as getAsyncClassMetadataFn, + ɵgetComponentDef as getComponentDef, ɵgetUnknownElementStrictMode as getUnknownElementStrictMode, ɵgetUnknownPropertyStrictMode as getUnknownPropertyStrictMode, + ɵinferTagNameFromDefinition as inferTagNameFromDefinition, InjectOptions, Injector, NgModule, @@ -38,8 +40,6 @@ import { ɵsetUnknownPropertyStrictMode as setUnknownPropertyStrictMode, ɵstringify as stringify, Type, - ɵinferTagNameFromDefinition as inferTagNameFromDefinition, - ɵgetComponentDef as getComponentDef, } from '../../src/core'; import {ComponentFixture} from './component_fixture'; @@ -679,10 +679,14 @@ export class TestBedImpl implements TestBed { createComponent(type: Type, options?: TestComponentOptions): ComponentFixture { if (getAsyncClassMetadataFn(type)) { - throw new Error( - `Component '${type.name}' has unresolved metadata. ` + - `Please call \`await TestBed.compileComponents()\` before running this test.`, - ); + const isCompiled = !!getComponentDef(type); + + if (!isCompiled) { + throw new Error( + `Component '${type.name}' has unresolved metadata. ` + + `Please call \`await TestBed.compileComponents()\` before running this test.`, + ); + } } // Note: injecting the renderer before accessing the definition appears to be load-bearing. diff --git a/packages/core/testing/src/test_bed_compiler.ts b/packages/core/testing/src/test_bed_compiler.ts index 3ace6504b099..c198e17d5a93 100644 --- a/packages/core/testing/src/test_bed_compiler.ts +++ b/packages/core/testing/src/test_bed_compiler.ts @@ -72,6 +72,7 @@ import { TestBedApplicationErrorHandler, } from './application_error_handler'; import {MetadataOverride} from './metadata_override'; +import {MetadataOverrider} from './metadata_overrider'; import { ComponentResolver, DirectiveResolver, @@ -281,6 +282,10 @@ export class TestBedCompiler { this.pendingPipes.add(pipe); } + hasComponentOverrides(type: Type): boolean { + return this.resolvers.component.hasOverrides(type); + } + private verifyNoStandaloneFlagOverrides( type: Type, override: MetadataOverride, @@ -507,8 +512,57 @@ export class TestBedCompiler { needsAsyncResources = needsAsyncResources || ɵisComponentDefPendingResolution(declaration); - const metadata = this.resolvers.component.resolve(declaration); + // Note: we can't use `this.resolvers.component.resolve(declaration)` here, because + // that function requires the component to have a decorator, which is not the case + // for AOT components. + let metadata = this.resolvers.component.resolve(declaration); if (metadata === null) { + if (this.resolvers.component.hasOverrides(declaration)) { + const componentDef = getComponentDef(declaration); + if (componentDef) { + // We have a component without metadata, but with a definition (AOT) and overrides. + // We can manually apply the overrides to the definition. + const overrider = new MetadataOverrider(); + metadata = new Component({ + selector: componentDef.selectors[0][0] as string, + template: componentDef.template as any, + standalone: componentDef.standalone, + }); + + const overrides = this.resolvers.component.getOverrides(declaration); + if (overrides) { + overrides.forEach((override) => { + metadata = overrider.overrideMetadata(Component, metadata!, override); + }); + } + + if (metadata!.providers) { + const providers = metadata!.providers; + const providersResolver = ( + ndef: DirectiveDef, + processProvidersFn?: (providers: Provider[]) => Provider[], + ) => { + return processProvidersFn ? processProvidersFn(providers) : providers; + }; + componentDef.providersResolver = providersResolver; + } + + if ((metadata as Component).viewProviders) { + const viewProviders = (metadata as Component).viewProviders!; + const viewProvidersResolver = ( + ndef: DirectiveDef, + processProvidersFn?: (providers: Provider[]) => Provider[], + ) => { + return processProvidersFn ? processProvidersFn(viewProviders) : viewProviders; + }; + componentDef.viewProvidersResolver = viewProvidersResolver; + } + + // We manually patched ɵcmp. + // We must skip compileComponent to avoid overwriting our work (and to avoid failure). + return; + } + } throw invalidTypeError(declaration.name, 'Component'); } diff --git a/packages/platform-browser-dynamic/test/testing_public_browser_spec.ts b/packages/platform-browser-dynamic/test/testing_public_browser_spec.ts index 7ed7210ea482..ba4880364137 100644 --- a/packages/platform-browser-dynamic/test/testing_public_browser_spec.ts +++ b/packages/platform-browser-dynamic/test/testing_public_browser_spec.ts @@ -128,8 +128,22 @@ if (isBrowser) { describe('errors', () => { describe('should fail when an ResourceLoader fails', () => { - // TODO(alxhub): figure out why this is failing on browsers - xit('should fail with an error from a promise', async () => { + beforeEach(() => { + getPlatform()?.destroy(); + TestBed.resetTestEnvironment(); + TestBed.initTestEnvironment( + [BrowserDynamicTestingModule], + platformBrowserDynamicTesting(), + ); + }); + + afterEach(() => { + getPlatform()?.destroy(); + TestBed.resetTestEnvironment(); + TestBed.initTestEnvironment([BrowserTestingModule], platformBrowserTesting()); + }); + + it('should fail with an error from a promise', async () => { @Component({ selector: 'bad-template-comp', templateUrl: 'non-existent.html', @@ -146,8 +160,25 @@ if (isBrowser) { }); describe('TestBed createComponent', function () { - // TODO(alxhub): disable while we figure out how this should work - xit('should allow an external templateUrl', waitForAsync(() => { + beforeEach(() => { + getPlatform()?.destroy(); + TestBed.resetTestEnvironment(); + TestBed.initTestEnvironment([BrowserDynamicTestingModule], platformBrowserDynamicTesting()); + }); + + afterEach(() => { + getPlatform()?.destroy(); + TestBed.resetTestEnvironment(); + TestBed.initTestEnvironment([BrowserTestingModule], platformBrowserTesting()); + }); + + it('should allow an external templateUrl', async () => { + class MockResourceLoader implements ResourceLoader { + get(url: string): Promise { + return Promise.resolve('from external template'); + } + } + @Component({ selector: 'external-template-comp', templateUrl: '/base/angular/packages/platform-browser/test/static_assets/test.html', @@ -156,13 +187,15 @@ if (isBrowser) { class ExternalTemplateComp {} TestBed.configureTestingModule({declarations: [ExternalTemplateComp]}); - TestBed.compileComponents().then(() => { - const componentFixture = TestBed.createComponent(ExternalTemplateComp); - componentFixture.detectChanges(); - expect(componentFixture.nativeElement.textContent).toEqual('from external template'); + TestBed.configureCompiler({ + providers: [{provide: ResourceLoader, useClass: MockResourceLoader}], }); - }), 10000); // Long timeout here because this test makes an actual ResourceLoader - // request, and is slow on Edge. + + await TestBed.compileComponents(); + const componentFixture = TestBed.createComponent(ExternalTemplateComp); + componentFixture.detectChanges(); + expect(componentFixture.nativeElement.textContent).toEqual('from external template'); + }); }); }); } diff --git a/packages/platform-browser/test/testing_public_spec.ts b/packages/platform-browser/test/testing_public_spec.ts index f5c3e43cbabf..d1fbc2d2635b 100644 --- a/packages/platform-browser/test/testing_public_spec.ts +++ b/packages/platform-browser/test/testing_public_spec.ts @@ -395,7 +395,7 @@ describe('public testing API', () => { xdescribe('components with template url', () => { let TestComponent!: Type; - beforeEach(waitForAsync(async () => { + beforeEach(() => { @Component({ selector: 'comp', templateUrl: '/base/angular/packages/platform-browser/test/static_assets/test.html', @@ -406,8 +406,7 @@ describe('public testing API', () => { TestComponent = CompWithUrlTemplate; TestBed.configureTestingModule({declarations: [CompWithUrlTemplate]}); - await TestBed.compileComponents(); - })); + }); isBrowser && it('should allow to createSync components with templateUrl after explicit async compilation', () => { @@ -913,7 +912,6 @@ describe('public testing API', () => { providers: [{provide: ResourceLoader, useValue: {get: resourceLoaderGet}}], }); - TestBed.compileComponents(); tick(); const compFixture = TestBed.createComponent(InternalCompWithUrlTemplate); expect(compFixture.nativeElement).toHaveText('Hello world!');