From 40adb117485dab5c3d7fee230d6863262ab144d7 Mon Sep 17 00:00:00 2001 From: Charles Lyding <19598772+clydin@users.noreply.github.com> Date: Thu, 9 Jul 2026 18:32:28 -0400 Subject: [PATCH] fix(@angular/build): warn when TestBed.overrideComponent is used in AOT mode TestBed.overrideComponent is a JIT-first API that relies on runtime decorator metadata to dynamically recompile components. When tests are compiled in AOT mode (which is the default for vitest and some karma configurations), decorator metadata is compiled away, meaning template or imports overrides can fail silently or cause confusing NG0304/NG0303 errors. To improve developer experience, this adds a runtime warning when TestBed.overrideComponent is called on an AOT-compiled component in AOT mode. A warning is printed to the console suggesting to disable AOT (aot: false) in the test build configuration as a workaround. --- .../src/builders/karma/application_builder.ts | 48 +++++- .../unit-test/runners/vitest/build-options.ts | 51 +++++++ .../behavior/aot-override-warning_spec.ts | 140 ++++++++++++++++++ 3 files changed, 238 insertions(+), 1 deletion(-) create mode 100644 packages/angular/build/src/builders/unit-test/tests/behavior/aot-override-warning_spec.ts diff --git a/packages/angular/build/src/builders/karma/application_builder.ts b/packages/angular/build/src/builders/karma/application_builder.ts index b9a28e1bf0b0..e3d4ded1812b 100644 --- a/packages/angular/build/src/builders/karma/application_builder.ts +++ b/packages/angular/build/src/builders/karma/application_builder.ts @@ -11,7 +11,6 @@ import type { Config, ConfigOptions, FilePattern, InlinePluginDef, Server } from import { randomUUID } from 'node:crypto'; import { rmSync } from 'node:fs'; import * as fs from 'node:fs/promises'; -import { createRequire } from 'node:module'; import path from 'node:path'; import { ReadableStream } from 'node:stream/web'; import { createVirtualModulePlugin } from '../../tools/esbuild/virtual-module-plugin'; @@ -225,6 +224,53 @@ async function runEsbuild( `});`, ]; + if (buildOptions.aot !== false) { + contents.push( + `const originalOverrideComponent = getTestBed().overrideComponent;`, + `const warnTracker = new Set();`, + `const unsafeKeys = new Set([`, + ` 'template',`, + ` 'templateUrl',`, + ` 'imports',`, + ` 'declarations',`, + ` 'exports',`, + ` 'schemas',`, + ` 'changeDetection',`, + ` 'styleUrls',`, + ` 'styleUrl',`, + ` 'styles',`, + ` 'animations',`, + ` 'encapsulation',`, + `]);`, + `function hasUnsafeOverride(override) {`, + ` if (!override) return false;`, + ` for (const key of ['add', 'remove', 'set']) {`, + ` const value = override[key];`, + ` if (value && typeof value === 'object') {`, + ` for (const prop of Object.keys(value)) {`, + ` if (unsafeKeys.has(prop)) {`, + ` return true;`, + ` }`, + ` }`, + ` }`, + ` }`, + ` return false;`, + `}`, + `getTestBed().overrideComponent = function (component, override) {`, + ` const isAotComponent = !!component.ɵcmp;`, + ` if (isAotComponent && hasUnsafeOverride(override) && !warnTracker.has(component)) {`, + ` warnTracker.add(component);`, + ` console.warn(`, + ` "[Angular] WARNING: 'TestBed.overrideComponent' was called on '" + component.name + "' with template/import/schema overrides in AOT mode. " +`, + ` "This is not fully supported and may cause NG0304/NG0303 element resolution errors. \\n" +`, + ` "👉 Workaround: Set 'aot: false' in the build configuration used for tests (e.g. 'buildTarget' in angular.json)."`, + ` );`, + ` }`, + ` return originalOverrideComponent.call(this, component, override);`, + `};`, + ); + } + return { contents: contents.join('\n'), loader: 'js', diff --git a/packages/angular/build/src/builders/unit-test/runners/vitest/build-options.ts b/packages/angular/build/src/builders/unit-test/runners/vitest/build-options.ts index 7f0f67fc0c2e..73443b67bd5e 100644 --- a/packages/angular/build/src/builders/unit-test/runners/vitest/build-options.ts +++ b/packages/angular/build/src/builders/unit-test/runners/vitest/build-options.ts @@ -35,6 +35,7 @@ function createTestBedInitVirtualFile( teardown: boolean, zoneTestingStrategy: 'none' | 'static' | 'dynamic' | 'dynamic-zone', hasLocalize: boolean, + isAot: boolean, ): string { let providersImport = 'const providers = [];'; if (providersFile) { @@ -127,6 +128,55 @@ function createTestBedInitVirtualFile( errorOnUnknownProperties: true, ${teardown === false ? 'teardown: { destroyAfterEach: false },' : ''} }); + + ${ + isAot + ? ` + const originalOverrideComponent = getTestBed().overrideComponent; + const warnTracker = new Set(); + const unsafeKeys = new Set([ + 'template', + 'templateUrl', + 'imports', + 'declarations', + 'exports', + 'schemas', + 'changeDetection', + 'styleUrls', + 'styleUrl', + 'styles', + 'animations', + 'encapsulation', + ]); + function hasUnsafeOverride(override) { + if (!override) return false; + for (const key of ['add', 'remove', 'set']) { + const value = override[key]; + if (value && typeof value === 'object') { + for (const prop of Object.keys(value)) { + if (unsafeKeys.has(prop)) { + return true; + } + } + } + } + return false; + } + getTestBed().overrideComponent = function (component, override) { + const isAotComponent = !!component.ɵcmp; + if (isAotComponent && hasUnsafeOverride(override) && !warnTracker.has(component)) { + warnTracker.add(component); + console.warn( + \`[Angular] WARNING: 'TestBed.overrideComponent' was called on '\${component.name}' with template/import/schema overrides in AOT mode. \` + + \`This is not fully supported and may cause NG0304/NG0303 element resolution errors. \\n\` + + \`👉 Workaround: Set 'aot: false' in the build configuration used for tests (e.g. 'buildTarget' in angular.json).\` + ); + } + return originalOverrideComponent.call(this, component, override); + }; + ` + : '' + } } `; } @@ -279,6 +329,7 @@ export async function getVitestBuildOptions( !options.debug, zoneTestingStrategy, hasLocalize, + buildOptions.aot !== false, ); const mockPatchContents = ` diff --git a/packages/angular/build/src/builders/unit-test/tests/behavior/aot-override-warning_spec.ts b/packages/angular/build/src/builders/unit-test/tests/behavior/aot-override-warning_spec.ts new file mode 100644 index 000000000000..40198304ebd8 --- /dev/null +++ b/packages/angular/build/src/builders/unit-test/tests/behavior/aot-override-warning_spec.ts @@ -0,0 +1,140 @@ +import { execute } from '../../index'; +import { + BASE_OPTIONS, + describeBuilder, + UNIT_TEST_BUILDER_INFO, + setupApplicationTarget, +} from '../setup'; + +describeBuilder(execute, UNIT_TEST_BUILDER_INFO, (harness) => { + describe('Behavior: "TestBed.overrideComponent warning in AOT"', () => { + it('should warn when overrideComponent is called in AOT mode', async () => { + setupApplicationTarget(harness, { + aot: true, + }); + + harness.useTarget('test', { + ...BASE_OPTIONS, + }); + + harness.writeFile( + 'src/app/aot-warning.spec.ts', + ` + import { Component } from '@angular/core'; + import { TestBed } from '@angular/core/testing'; + import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest'; + + @Component({ + selector: 'test-comp', + template: '', + }) + class TestComponent {} + + describe('Override Warning', () => { + let warnSpy: any; + + beforeEach(() => { + warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + }); + + afterEach(() => { + warnSpy.mockRestore(); + }); + + it('should log warning when overriding template', () => { + TestBed.configureTestingModule({ + imports: [TestComponent], + }).overrideComponent(TestComponent, { + set: { template: 'new template' } + }); + + expect(warnSpy).toHaveBeenCalled(); + expect(warnSpy.mock.calls[0][0]).toContain('WARNING: \\'TestBed.overrideComponent\\' was called'); + }); + + it('should NOT log warning when only overriding providers', () => { + TestBed.configureTestingModule({ + imports: [TestComponent], + }).overrideComponent(TestComponent, { + set: { providers: [] } + }); + + expect(warnSpy).not.toHaveBeenCalled(); + }); + }); + `, + ); + + // Overwrite default to avoid noise + harness.writeFile( + 'src/app/app.component.spec.ts', + ` + import { describe, it, expect } from 'vitest'; + describe('Ignored', () => { it('pass', () => expect(true).toBe(true)); }); + `, + ); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + }); + + it('should NOT warn when overrideComponent is called in JIT mode', async () => { + setupApplicationTarget(harness, { + aot: false, + }); + + harness.useTarget('test', { + ...BASE_OPTIONS, + }); + + harness.writeFile( + 'src/app/jit-no-warning.spec.ts', + ` + import { Component } from '@angular/core'; + import { TestBed } from '@angular/core/testing'; + import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest'; + + @Component({ + selector: 'test-comp', + template: '', + }) + class TestComponent {} + + describe('JIT No Warning', () => { + let warnSpy: any; + + beforeEach(() => { + warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + }); + + afterEach(() => { + warnSpy.mockRestore(); + }); + + it('should not log warning', () => { + TestBed.configureTestingModule({ + imports: [TestComponent], + }).overrideComponent(TestComponent, { + set: { template: 'new template' } + }); + + expect(warnSpy).not.toHaveBeenCalled(); + }); + }); + `, + ); + + // Overwrite default to avoid noise + harness.writeFile( + 'src/app/app.component.spec.ts', + ` + import { describe, it, expect } from 'vitest'; + describe('Ignored', () => { it('pass', () => expect(true).toBe(true)); }); + `, + ); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + }); + }); +});