Skip to content

Commit 40adb11

Browse files
committed
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.
1 parent 035c72a commit 40adb11

3 files changed

Lines changed: 238 additions & 1 deletion

File tree

packages/angular/build/src/builders/karma/application_builder.ts

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,6 @@ import type { Config, ConfigOptions, FilePattern, InlinePluginDef, Server } from
1111
import { randomUUID } from 'node:crypto';
1212
import { rmSync } from 'node:fs';
1313
import * as fs from 'node:fs/promises';
14-
import { createRequire } from 'node:module';
1514
import path from 'node:path';
1615
import { ReadableStream } from 'node:stream/web';
1716
import { createVirtualModulePlugin } from '../../tools/esbuild/virtual-module-plugin';
@@ -225,6 +224,53 @@ async function runEsbuild(
225224
`});`,
226225
];
227226

227+
if (buildOptions.aot !== false) {
228+
contents.push(
229+
`const originalOverrideComponent = getTestBed().overrideComponent;`,
230+
`const warnTracker = new Set();`,
231+
`const unsafeKeys = new Set([`,
232+
` 'template',`,
233+
` 'templateUrl',`,
234+
` 'imports',`,
235+
` 'declarations',`,
236+
` 'exports',`,
237+
` 'schemas',`,
238+
` 'changeDetection',`,
239+
` 'styleUrls',`,
240+
` 'styleUrl',`,
241+
` 'styles',`,
242+
` 'animations',`,
243+
` 'encapsulation',`,
244+
`]);`,
245+
`function hasUnsafeOverride(override) {`,
246+
` if (!override) return false;`,
247+
` for (const key of ['add', 'remove', 'set']) {`,
248+
` const value = override[key];`,
249+
` if (value && typeof value === 'object') {`,
250+
` for (const prop of Object.keys(value)) {`,
251+
` if (unsafeKeys.has(prop)) {`,
252+
` return true;`,
253+
` }`,
254+
` }`,
255+
` }`,
256+
` }`,
257+
` return false;`,
258+
`}`,
259+
`getTestBed().overrideComponent = function (component, override) {`,
260+
` const isAotComponent = !!component.ɵcmp;`,
261+
` if (isAotComponent && hasUnsafeOverride(override) && !warnTracker.has(component)) {`,
262+
` warnTracker.add(component);`,
263+
` console.warn(`,
264+
` "[Angular] WARNING: 'TestBed.overrideComponent' was called on '" + component.name + "' with template/import/schema overrides in AOT mode. " +`,
265+
` "This is not fully supported and may cause NG0304/NG0303 element resolution errors. \\n" +`,
266+
` "👉 Workaround: Set 'aot: false' in the build configuration used for tests (e.g. 'buildTarget' in angular.json)."`,
267+
` );`,
268+
` }`,
269+
` return originalOverrideComponent.call(this, component, override);`,
270+
`};`,
271+
);
272+
}
273+
228274
return {
229275
contents: contents.join('\n'),
230276
loader: 'js',

packages/angular/build/src/builders/unit-test/runners/vitest/build-options.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ function createTestBedInitVirtualFile(
3535
teardown: boolean,
3636
zoneTestingStrategy: 'none' | 'static' | 'dynamic' | 'dynamic-zone',
3737
hasLocalize: boolean,
38+
isAot: boolean,
3839
): string {
3940
let providersImport = 'const providers = [];';
4041
if (providersFile) {
@@ -127,6 +128,55 @@ function createTestBedInitVirtualFile(
127128
errorOnUnknownProperties: true,
128129
${teardown === false ? 'teardown: { destroyAfterEach: false },' : ''}
129130
});
131+
132+
${
133+
isAot
134+
? `
135+
const originalOverrideComponent = getTestBed().overrideComponent;
136+
const warnTracker = new Set();
137+
const unsafeKeys = new Set([
138+
'template',
139+
'templateUrl',
140+
'imports',
141+
'declarations',
142+
'exports',
143+
'schemas',
144+
'changeDetection',
145+
'styleUrls',
146+
'styleUrl',
147+
'styles',
148+
'animations',
149+
'encapsulation',
150+
]);
151+
function hasUnsafeOverride(override) {
152+
if (!override) return false;
153+
for (const key of ['add', 'remove', 'set']) {
154+
const value = override[key];
155+
if (value && typeof value === 'object') {
156+
for (const prop of Object.keys(value)) {
157+
if (unsafeKeys.has(prop)) {
158+
return true;
159+
}
160+
}
161+
}
162+
}
163+
return false;
164+
}
165+
getTestBed().overrideComponent = function (component, override) {
166+
const isAotComponent = !!component.ɵcmp;
167+
if (isAotComponent && hasUnsafeOverride(override) && !warnTracker.has(component)) {
168+
warnTracker.add(component);
169+
console.warn(
170+
\`[Angular] WARNING: 'TestBed.overrideComponent' was called on '\${component.name}' with template/import/schema overrides in AOT mode. \` +
171+
\`This is not fully supported and may cause NG0304/NG0303 element resolution errors. \\n\` +
172+
\`👉 Workaround: Set 'aot: false' in the build configuration used for tests (e.g. 'buildTarget' in angular.json).\`
173+
);
174+
}
175+
return originalOverrideComponent.call(this, component, override);
176+
};
177+
`
178+
: ''
179+
}
130180
}
131181
`;
132182
}
@@ -279,6 +329,7 @@ export async function getVitestBuildOptions(
279329
!options.debug,
280330
zoneTestingStrategy,
281331
hasLocalize,
332+
buildOptions.aot !== false,
282333
);
283334

284335
const mockPatchContents = `
Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
import { execute } from '../../index';
2+
import {
3+
BASE_OPTIONS,
4+
describeBuilder,
5+
UNIT_TEST_BUILDER_INFO,
6+
setupApplicationTarget,
7+
} from '../setup';
8+
9+
describeBuilder(execute, UNIT_TEST_BUILDER_INFO, (harness) => {
10+
describe('Behavior: "TestBed.overrideComponent warning in AOT"', () => {
11+
it('should warn when overrideComponent is called in AOT mode', async () => {
12+
setupApplicationTarget(harness, {
13+
aot: true,
14+
});
15+
16+
harness.useTarget('test', {
17+
...BASE_OPTIONS,
18+
});
19+
20+
harness.writeFile(
21+
'src/app/aot-warning.spec.ts',
22+
`
23+
import { Component } from '@angular/core';
24+
import { TestBed } from '@angular/core/testing';
25+
import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest';
26+
27+
@Component({
28+
selector: 'test-comp',
29+
template: '',
30+
})
31+
class TestComponent {}
32+
33+
describe('Override Warning', () => {
34+
let warnSpy: any;
35+
36+
beforeEach(() => {
37+
warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
38+
});
39+
40+
afterEach(() => {
41+
warnSpy.mockRestore();
42+
});
43+
44+
it('should log warning when overriding template', () => {
45+
TestBed.configureTestingModule({
46+
imports: [TestComponent],
47+
}).overrideComponent(TestComponent, {
48+
set: { template: 'new template' }
49+
});
50+
51+
expect(warnSpy).toHaveBeenCalled();
52+
expect(warnSpy.mock.calls[0][0]).toContain('WARNING: \\'TestBed.overrideComponent\\' was called');
53+
});
54+
55+
it('should NOT log warning when only overriding providers', () => {
56+
TestBed.configureTestingModule({
57+
imports: [TestComponent],
58+
}).overrideComponent(TestComponent, {
59+
set: { providers: [] }
60+
});
61+
62+
expect(warnSpy).not.toHaveBeenCalled();
63+
});
64+
});
65+
`,
66+
);
67+
68+
// Overwrite default to avoid noise
69+
harness.writeFile(
70+
'src/app/app.component.spec.ts',
71+
`
72+
import { describe, it, expect } from 'vitest';
73+
describe('Ignored', () => { it('pass', () => expect(true).toBe(true)); });
74+
`,
75+
);
76+
77+
const { result } = await harness.executeOnce();
78+
expect(result?.success).toBeTrue();
79+
});
80+
81+
it('should NOT warn when overrideComponent is called in JIT mode', async () => {
82+
setupApplicationTarget(harness, {
83+
aot: false,
84+
});
85+
86+
harness.useTarget('test', {
87+
...BASE_OPTIONS,
88+
});
89+
90+
harness.writeFile(
91+
'src/app/jit-no-warning.spec.ts',
92+
`
93+
import { Component } from '@angular/core';
94+
import { TestBed } from '@angular/core/testing';
95+
import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest';
96+
97+
@Component({
98+
selector: 'test-comp',
99+
template: '',
100+
})
101+
class TestComponent {}
102+
103+
describe('JIT No Warning', () => {
104+
let warnSpy: any;
105+
106+
beforeEach(() => {
107+
warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
108+
});
109+
110+
afterEach(() => {
111+
warnSpy.mockRestore();
112+
});
113+
114+
it('should not log warning', () => {
115+
TestBed.configureTestingModule({
116+
imports: [TestComponent],
117+
}).overrideComponent(TestComponent, {
118+
set: { template: 'new template' }
119+
});
120+
121+
expect(warnSpy).not.toHaveBeenCalled();
122+
});
123+
});
124+
`,
125+
);
126+
127+
// Overwrite default to avoid noise
128+
harness.writeFile(
129+
'src/app/app.component.spec.ts',
130+
`
131+
import { describe, it, expect } from 'vitest';
132+
describe('Ignored', () => { it('pass', () => expect(true).toBe(true)); });
133+
`,
134+
);
135+
136+
const { result } = await harness.executeOnce();
137+
expect(result?.success).toBeTrue();
138+
});
139+
});
140+
});

0 commit comments

Comments
 (0)