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(); + }); + }); +});