Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,6 @@ import {
expectNoLog,
} from '../setup';

/**
* Maximum time in milliseconds for single build/rebuild
* This accounts for CI variability.
*/
export const BUILD_TIMEOUT = 30_000;

describeBuilder(buildApplication, APPLICATION_BUILDER_INFO, (harness) => {
describe('Behavior: "Rebuild Error Detection"', () => {
it('detects template errors with no AOT codegen or TS emit differences', async () => {
Expand Down Expand Up @@ -128,6 +122,235 @@ describeBuilder(buildApplication, APPLICATION_BUILDER_INFO, (harness) => {
);
});

it('detects template errors across components on watch rebuild when isolatedModules is enabled', async () => {
harness.useTarget('build', {
...BASE_OPTIONS,
watch: true,
});

await harness.modifyFile('tsconfig.json', (content) => {
const tsconfig = JSON.parse(content);
tsconfig.compilerOptions = {
...tsconfig.compilerOptions,
isolatedModules: true,
};

return JSON.stringify(tsconfig);
});

const goodChildComponentContents = `
import { Component, Input } from '@angular/core';
@Component({
selector: 'child',
standalone: true,
template: '<p>{{ value }}</p>',
})
export class ChildComponent {
@Input() value!: number;
}
`;

const typeErrorText = `Type 'number' is not assignable to type 'string'.`;

await harness.writeFiles({
'src/app/child.component.ts': goodChildComponentContents,
'src/app/app.module.ts': `
import { NgModule } from '@angular/core';
import { BrowserModule } from '@angular/platform-browser';
import { AppComponent } from './app.component';
@NgModule({
imports: [
BrowserModule,
AppComponent,
],
providers: [],
bootstrap: [AppComponent]
})
export class AppModule { }
`,
'src/app/app.component.ts': `
import { Component } from '@angular/core';
import { ChildComponent } from './child.component';
@Component({
selector: 'app-root',
standalone: true,
imports: [ChildComponent],
template: '<child [value]="123" />',
})
export class AppComponent {}
`,
});

await harness.executeWithCases(
[
async ({ result }) => {
expect(result?.success).toBeTrue();

// Update child component to change input type from number to string
await harness.writeFile(
'src/app/child.component.ts',
`
import { Component, Input } from '@angular/core';
@Component({
selector: 'child',
standalone: true,
template: '<p>{{ value }}</p>',
})
export class ChildComponent {
@Input() value!: string;
}
`,
);
},
async ({ result, logs }) => {
expect(result?.success).toBeFalse();
expectLog(logs, typeErrorText);

// Make an unrelated change to verify error persists
await harness.modifyFile('src/main.ts', (content) => content + '\n');
},
async ({ result, logs }) => {
expect(result?.success).toBeFalse();
expectLog(logs, typeErrorText);

// Revert back to number
await harness.writeFile('src/app/child.component.ts', goodChildComponentContents);
},
async ({ result, logs }) => {
expect(result?.success).toBeTrue();
expectNoLog(logs, typeErrorText);

// Make an unrelated change to verify error cache cleared
await harness.modifyFile('src/main.ts', (content) => content + '\n');
},
({ result, logs }) => {
expect(result?.success).toBeTrue();
expectNoLog(logs, typeErrorText);
},
],
{ outputLogsOnFailure: false },
);
});

it('detects template errors when an input becomes required or a selector is renamed on watch rebuild', async () => {
harness.useTarget('build', {
...BASE_OPTIONS,
watch: true,
});

await harness.modifyFile('tsconfig.json', (content) => {
const tsconfig = JSON.parse(content);
tsconfig.compilerOptions = {
...tsconfig.compilerOptions,
isolatedModules: true,
};

return JSON.stringify(tsconfig);
});

const goodChildComponentContents = `
import { Component, input } from '@angular/core';
@Component({
selector: 'app-child',
standalone: true,
template: '<p>{{ label() }}</p>',
})
export class ChildComponent {
label = input('');
}
`;

const requiredInputErrorText = `Required input 'label' from component ChildComponent must be specified.`;
const unknownElementErrorText = `'app-child' is not a known element`;

await harness.writeFiles({
'src/app/child.component.ts': goodChildComponentContents,
'src/app/app.module.ts': `
import { NgModule } from '@angular/core';
import { BrowserModule } from '@angular/platform-browser';
import { AppComponent } from './app.component';
@NgModule({
imports: [
BrowserModule,
AppComponent,
],
providers: [],
bootstrap: [AppComponent]
})
export class AppModule { }
`,
'src/app/app.component.ts': `
import { Component } from '@angular/core';
import { ChildComponent } from './child.component';
@Component({
selector: 'app-root',
standalone: true,
imports: [ChildComponent],
template: '<app-child />',
})
export class AppComponent {}
`,
});

await harness.executeWithCases(
[
async ({ result }) => {
expect(result?.success).toBeTrue();

// Make input required using signal input.required() (which has the same TS .d.ts type InputSignal<string>)
await harness.writeFile(
'src/app/child.component.ts',
`
import { Component, input } from '@angular/core';
@Component({
selector: 'app-child',
standalone: true,
template: '<p>{{ label() }}</p>',
})
export class ChildComponent {
label = input.required<string>();
}
`,
);
},
async ({ result, logs }) => {
expect(result?.success).toBeFalse();
expectLog(logs, requiredInputErrorText);

// Revert required input and rename selector instead
await harness.writeFile(
'src/app/child.component.ts',
`
import { Component, input } from '@angular/core';
@Component({
selector: 'app-child-renamed',
standalone: true,
template: '<p>{{ label() }}</p>',
})
export class ChildComponent {
label = input('');
}
`,
);
},
async ({ result, logs }) => {
expect(result?.success).toBeFalse();
expectNoLog(logs, requiredInputErrorText);
expectLog(logs, unknownElementErrorText);

// Revert back to valid child component
await harness.writeFile('src/app/child.component.ts', goodChildComponentContents);
},
({ result, logs }) => {
expect(result?.success).toBeTrue();
expectNoLog(logs, requiredInputErrorText);
expectNoLog(logs, unknownElementErrorText);
},
],
{ outputLogsOnFailure: false },
);
});

it('detects cumulative block syntax errors', async () => {
harness.useTarget('build', {
...BASE_OPTIONS,
Expand Down Expand Up @@ -234,7 +457,7 @@ describeBuilder(buildApplication, APPLICATION_BUILDER_INFO, (harness) => {
await harness.appendToFile('src/app/app.component.html', '<div>Guten Tag</div>');
},
({ logs }) => {
expectNoLog(logs, 'invalid-css-content');
expectNoLog(logs, 'Unexpected character "EOF"');

harness.expectFile('dist/browser/main.js').content.toContain('Hello, world!');
harness.expectFile('dist/browser/main.js').content.toContain('Guten Tag');
Expand Down
11 changes: 9 additions & 2 deletions packages/angular/build/src/tools/angular/angular-host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,10 @@ export interface AngularHostOptions {
* field. The `version` field is required when used with a TypeScript BuilderProgram.
* @param program The TypeScript Program instance to patch.
*/
export function ensureSourceFileVersions(program: ts.Program): void {
export function ensureSourceFileVersions(
program: ts.Program,
previousProgram?: ts.BuilderProgram,
): void {
const baseGetSourceFiles = program.getSourceFiles;
// TODO: Update Angular compiler to add versions to all internal files and remove this
program.getSourceFiles = function (...parameters) {
Expand All @@ -45,7 +48,11 @@ export function ensureSourceFileVersions(program: ts.Program): void {

for (const file of files) {
if (file.version === undefined) {
file.version = calculateHash(file.text);
const previousFile: (ts.SourceFile & { version?: string }) | undefined =
previousProgram && !file.fileName.endsWith('.ngtypecheck.ts')
? previousProgram.getSourceFile(file.fileName)
: undefined;
file.version = previousFile?.version ?? calculateHash(file.text);
}
}

Expand Down
Loading
Loading