Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 47 additions & 1 deletion packages/angular/build/src/builders/karma/application_builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@
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';
Expand Down Expand Up @@ -225,6 +224,53 @@
`});`,
];

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;`,
Comment on lines +259 to +260

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

If component is null or undefined, accessing component.ɵcmp or component.name will throw a TypeError before the original overrideComponent method can be called. Adding a guard clause to check if component is truthy ensures defensive programming and delegates invalid arguments safely to the original method.

Suggested change
`getTestBed().overrideComponent = function (component, override) {`,
` const isAotComponent = !!component.ɵcmp;`,
`getTestBed().overrideComponent = function (component, override) {`,
` if (!component) {`,
` return originalOverrideComponent.call(this, 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. " +`,

Check failure on line 264 in packages/angular/build/src/builders/karma/application_builder.ts

View workflow job for this annotation

GitHub Actions / lint

This line has a length of 160. Maximum allowed is 140
` "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',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
teardown: boolean,
zoneTestingStrategy: 'none' | 'static' | 'dynamic' | 'dynamic-zone',
hasLocalize: boolean,
isAot: boolean,
): string {
let providersImport = 'const providers = [];';
if (providersFile) {
Expand Down Expand Up @@ -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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

If component is null or undefined, accessing component.ɵcmp or component.name will throw a TypeError before the original overrideComponent method can be called. Adding a guard clause to check if component is truthy ensures defensive programming and delegates invalid arguments safely to the original method.

Suggested change
getTestBed().overrideComponent = function (component, override) {
const isAotComponent = !!component.ɵcmp;
getTestBed().overrideComponent = function (component, override) {
if (!component) {
return originalOverrideComponent.call(this, 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. \` +

Check failure on line 170 in packages/angular/build/src/builders/unit-test/runners/vitest/build-options.ts

View workflow job for this annotation

GitHub Actions / lint

This line has a length of 151. Maximum allowed is 140
\`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);
};
`
: ''
}
}
`;
}
Expand Down Expand Up @@ -279,6 +329,7 @@
!options.debug,
zoneTestingStrategy,
hasLocalize,
buildOptions.aot !== false,
);

const mockPatchContents = `
Expand Down
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();
});
});
});
Loading