-
Notifications
You must be signed in to change notification settings - Fork 11.9k
fix(@angular/build): warn when TestBed.overrideComponent is used in AOT mode #33542
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -35,6 +35,7 @@ | |||||||||||||||
| teardown: boolean, | ||||||||||||||||
| zoneTestingStrategy: 'none' | 'static' | 'dynamic' | 'dynamic-zone', | ||||||||||||||||
| hasLocalize: boolean, | ||||||||||||||||
| isAot: boolean, | ||||||||||||||||
| ): string { | ||||||||||||||||
| let providersImport = 'const providers = [];'; | ||||||||||||||||
| if (providersFile) { | ||||||||||||||||
|
|
@@ -127,6 +128,55 @@ | |||||||||||||||
| 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; | ||||||||||||||||
|
Comment on lines
+165
to
+166
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. If
Suggested change
|
||||||||||||||||
| 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 @@ | |||||||||||||||
| !options.debug, | ||||||||||||||||
| zoneTestingStrategy, | ||||||||||||||||
| hasLocalize, | ||||||||||||||||
| buildOptions.aot !== false, | ||||||||||||||||
| ); | ||||||||||||||||
|
|
||||||||||||||||
| const mockPatchContents = ` | ||||||||||||||||
|
|
||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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(); | ||
| }); | ||
| }); | ||
| }); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If
componentis null or undefined, accessingcomponent.ɵcmporcomponent.namewill throw aTypeErrorbefore the originaloverrideComponentmethod can be called. Adding a guard clause to check ifcomponentis truthy ensures defensive programming and delegates invalid arguments safely to the original method.