angular-cn/packages/compiler-cli/test/ngtsc/incremental_error_spec.ts

581 lines
17 KiB
TypeScript

/**
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.io/license
*/
import {absoluteFrom as _} from '../../src/ngtsc/file_system';
import {runInEachFileSystem} from '../../src/ngtsc/file_system/testing';
import {loadStandardTestFiles} from '../../src/ngtsc/testing';
import {NgtscTestEnvironment} from './env';
const testFiles = loadStandardTestFiles();
runInEachFileSystem(() => {
describe('ngtsc incremental compilation with errors', () => {
let env!: NgtscTestEnvironment;
beforeEach(() => {
env = NgtscTestEnvironment.setup(testFiles);
env.enableMultipleCompilations();
env.tsconfig();
// This file is part of the program, but not referenced by anything else. It can be used by
// each test to verify that it isn't re-emitted after incremental builds.
env.write('unrelated.ts', `
export class Unrelated {}
`);
});
function expectToHaveWritten(files: string[]): void {
const set = env.getFilesWrittenSinceLastFlush();
const expectedSet = new Set<string>();
for (const file of files) {
expectedSet.add(file);
expectedSet.add(file.replace(/\.js$/, '.d.ts'));
}
expect(set).toEqual(expectedSet);
// Reset for the next compilation.
env.flushWrittenFileTracking();
}
it('should handle an error in an unrelated file', () => {
env.write('cmp.ts', `
import {Component} from '@angular/core';
@Component({selector: 'test-cmp', template: '...'})
export class TestCmp {}
`);
env.write('other.ts', `
export class Other {}
`);
// Start with a clean compilation.
env.driveMain();
env.flushWrittenFileTracking();
// Introduce the error.
env.write('other.ts', `
export class Other // missing braces
`);
const diags = env.driveDiagnostics();
expect(diags.length).toBe(1);
expect(diags[0].file!.fileName).toBe(_('/other.ts'));
expectToHaveWritten([]);
// Remove the error. /other.js should now be emitted again.
env.write('other.ts', `
export class Other {}
`);
env.driveMain();
expectToHaveWritten(['/other.js']);
});
it('should emit all files after an error on the initial build', () => {
// Intentionally start with a broken compilation.
env.write('cmp.ts', `
import {Component} from '@angular/core';
@Component({selector: 'test-cmp', template: '...'})
export class TestCmp {}
`);
env.write('other.ts', `
export class Other // missing braces
`);
const diags = env.driveDiagnostics();
expect(diags.length).toBe(1);
expect(diags[0].file!.fileName).toBe(_('/other.ts'));
expectToHaveWritten([]);
// Remove the error. All files should be emitted.
env.write('other.ts', `
export class Other {}
`);
env.driveMain();
expectToHaveWritten(['/cmp.js', '/other.js', '/unrelated.js']);
});
it('should emit files introduced at the same time as an unrelated error', () => {
env.write('other.ts', `
// Needed so that the initial program contains @angular/core's .d.ts file.
import '@angular/core';
export class Other {}
`);
// Clean compile.
env.driveMain();
env.flushWrittenFileTracking();
env.write('cmp.ts', `
import {Component} from '@angular/core';
@Component({selector: 'test-cmp', template: '...'})
export class TestCmp {}
`);
env.write('other.ts', `
export class Other // missing braces
`);
const diags = env.driveDiagnostics();
expect(diags.length).toBe(1);
expect(diags[0].file!.fileName).toBe(_('/other.ts'));
expectToHaveWritten([]);
// Remove the error. All files should be emitted.
env.write('other.ts', `
export class Other {}
`);
env.driveMain();
expectToHaveWritten(['/cmp.js', '/other.js']);
});
it('should emit dependent files even in the face of an error', () => {
env.write('cmp.ts', `
import {Component} from '@angular/core';
import {SELECTOR} from './selector';
@Component({selector: SELECTOR, template: '...'})
export class TestCmp {}
`);
env.write('selector.ts', `
export const SELECTOR = 'test-cmp';
`);
env.write('other.ts', `
// Needed so that the initial program contains @angular/core's .d.ts file.
import '@angular/core';
export class Other {}
`);
// Clean compile.
env.driveMain();
env.flushWrittenFileTracking();
env.write('cmp.ts', `
import {Component} from '@angular/core';
@Component({selector: 'test-cmp', template: '...'})
export class TestCmp {}
`);
env.write('other.ts', `
export class Other // missing braces
`);
const diags = env.driveDiagnostics();
expect(diags.length).toBe(1);
expect(diags[0].file!.fileName).toBe(_('/other.ts'));
expectToHaveWritten([]);
// Remove the error. All files should be emitted.
env.write('other.ts', `
export class Other {}
`);
env.driveMain();
expectToHaveWritten(['/cmp.js', '/other.js']);
});
it('should recover from an error in a component\'s metadata', () => {
env.write('test.ts', `
import {Component} from '@angular/core';
@Component({selector: 'test-cmp', template: '...'})
export class TestCmp {}
`);
// Start with a clean compilation.
env.driveMain();
env.flushWrittenFileTracking();
// Introduce the error.
env.write('test.ts', `
import {Component} from '@angular/core';
@Component({selector: 'test-cmp', template: ...}) // invalid template
export class TestCmp {}
`);
const diags = env.driveDiagnostics();
expect(diags.length).toBeGreaterThan(0);
expectToHaveWritten([]);
// Clear the error and verify that the compiler now emits test.js again.
env.write('test.ts', `
import {Component} from '@angular/core';
@Component({selector: 'test-cmp', template: '...'})
export class TestCmp {}
`);
env.driveMain();
expectToHaveWritten(['/test.js']);
});
it('should recover from an error in a component that is part of a module', () => {
// In this test, there are two components, TestCmp and TargetCmp, that are part of the same
// NgModule. TestCmp is broken in an incremental build and then fixed, and the test verifies
// that TargetCmp is re-emitted.
env.write('test.ts', `
import {Component} from '@angular/core';
@Component({selector: 'test-cmp', template: '...'})
export class TestCmp {}
`);
env.write('target.ts', `
import {Component} from '@angular/core';
@Component({selector: 'target-cmp', template: '<test-cmp></test-cmp>'})
export class TargetCmp {}
`);
env.write('module.ts', `
import {NgModule, NO_ERRORS_SCHEMA} from '@angular/core';
import {TargetCmp} from './target';
import {TestCmp} from './test';
@NgModule({
declarations: [TestCmp, TargetCmp],
schemas: [NO_ERRORS_SCHEMA],
})
export class Module {}
`);
// Start with a clean compilation.
env.driveMain();
env.flushWrittenFileTracking();
// Introduce the syntactic error.
env.write('test.ts', `
import {Component} from '@angular/core';
@Component({selector: ..., template: '...'}) // ... is not valid syntax
export class TestCmp {}
`);
const diags = env.driveDiagnostics();
expect(diags.length).toBeGreaterThan(0);
expectToHaveWritten([]);
// Clear the error and trigger the rebuild.
env.write('test.ts', `
import {Component} from '@angular/core';
@Component({selector: 'test-cmp-fixed', template: '...'})
export class TestCmp {}
`);
env.driveMain();
expectToHaveWritten([
// The file which had the error should have been emitted, of course.
'/test.js',
// Because TestCmp belongs to a module, the module's file should also have been
// re-emitted.
'/module.js',
// Because TargetCmp also belongs to the same module, it should be re-emitted since
// TestCmp's selector was changed.
'/target.js',
]);
});
it('should recover from an error in an external template', () => {
env.write('mod.ts', `
import {NgModule} from '@angular/core';
import {Cmp} from './cmp';
@NgModule({
declarations: [Cmp],
})
export class Mod {}
`);
env.write('cmp.html', '{{ error = "true" }} ');
env.write('cmp.ts', `
import {Component} from '@angular/core';
@Component({
templateUrl: './cmp.html',
selector: 'some-cmp',
})
export class Cmp {
error = 'false';
}
`);
// Diagnostics should show for the broken component template.
expect(env.driveDiagnostics().length).toBeGreaterThan(0);
env.write('cmp.html', '{{ error }} ');
// There should be no diagnostics.
env.driveMain();
});
it('should recover from an error even across multiple NgModules', () => {
// This test is a variation on the above. Two components (CmpA and CmpB) exist in an NgModule,
// which indirectly imports a LibModule (via another NgModule in the middle). The test is
// designed to verify that CmpA and CmpB are re-emitted if somewhere upstream in the NgModule
// graph, an error is fixed. To check this, LibModule is broken and then fixed in incremental
// build steps.
env.write('a.ts', `
import {Component} from '@angular/core';
@Component({selector: 'test-cmp', template: '<div dir></div>'})
export class CmpA {}
`);
env.write('b.ts', `
import {Component} from '@angular/core';
@Component({selector: 'target-cmp', template: '...'})
export class CmpB {}
`);
env.write('module.ts', `
import {NgModule} from '@angular/core';
import {LibModule} from './lib';
import {CmpA} from './a';
import {CmpB} from './b';
@NgModule({
imports: [LibModule],
exports: [LibModule],
})
export class IndirectModule {}
@NgModule({
declarations: [CmpA, CmpB],
imports: [IndirectModule],
})
export class Module {}
`);
env.write('lib.ts', `
import {Directive, NgModule} from '@angular/core';
@Directive({
selector: '[dir]',
})
export class LibDir {}
@NgModule({
declarations: [LibDir],
exports: [LibDir],
})
export class LibModule {}
`);
// Start with a clean compilation.
env.driveMain();
env.flushWrittenFileTracking();
// Introduce the error in LibModule
env.write('lib.ts', `
import {Directive, NgModule} from '@angular/core';
@Directive({
selector: '[dir]',
})
export class LibDir {}
@Directive({
selector: '[dir]',
})
export class NewDir {}
@NgModule({
declarations: [NewDir],
})
export class NewModule {}
@NgModule({
declarations: [LibDir],
imports: [NewModule],
exports: [LibDir, NewModule],
})
export class LibModule // missing braces
`);
// env.driveMain();
const diags = env.driveDiagnostics();
expect(diags.length).toBeGreaterThan(0);
expectToHaveWritten([]);
// Clear the error and recompile.
env.write('lib.ts', `
import {Component, NgModule} from '@angular/core';
@Component({
selector: 'lib-cmp',
template: '...',
})
export class LibCmp {}
@NgModule({})
export class NewModule {}
@NgModule({
declarations: [LibCmp],
imports: [NewModule],
exports: [LibCmp, NewModule],
})
export class LibModule {}
`);
env.driveMain();
expectToHaveWritten([
// CmpA should be re-emitted as `NewModule` was added since the successful emit, which added
// `NewDir` as a matching directive to CmpA. Alternatively, CmpB should not be re-emitted
// as it does not use the newly added directive.
'/a.js',
// So should the module itself.
'/module.js',
// And of course, the file with the error.
'/lib.js',
]);
});
describe('chained errors', () => {
it('should remember a change to a TS file across broken builds', () => {
// Two components, an NgModule, and a random file.
writeTwoComponentSystem(env);
writeRandomFile(env, 'other.ts');
// Start with a clean build.
env.driveMain();
env.flushWrittenFileTracking();
// Update ACmp
env.write('a.ts', `
import {Component} from '@angular/core';
@Component({selector: 'a-cmp', template: 'new template'})
export class ACmp {}
`);
// Update the file to have an error, simultaneously.
writeRandomFile(env, 'other.ts', {error: true});
// This build should fail.
const diags = env.driveDiagnostics();
expect(diags.length).not.toBe(0);
expectToHaveWritten([]);
// Fix the error.
writeRandomFile(env, 'other.ts');
// Rebuild.
env.driveMain();
// If the compiler behaves correctly, it should remember that 'a.ts' was updated before, and
// should regenerate b.ts.
expectToHaveWritten([
// Because they directly changed
'/other.js',
'/a.js',
// Because they depend on a.ts
'/module.js',
]);
});
it('should remember a change to a template file across broken builds', () => {
// This is basically the same test as above, except a.html is changed instead of a.ts.
// Two components, an NgModule, and a random file.
writeTwoComponentSystem(env);
writeRandomFile(env, 'other.ts');
// Start with a clean build.
env.driveMain();
env.flushWrittenFileTracking();
// Invalidate ACmp's template.
env.write('a.html', 'Changed template');
// Update the file to have an error, simultaneously.
writeRandomFile(env, 'other.ts', {error: true});
// This build should fail.
const diags = env.driveDiagnostics();
expect(diags.length).not.toBe(0);
expectToHaveWritten([]);
// Fix the error.
writeRandomFile(env, 'other.ts');
// Rebuild.
env.flushWrittenFileTracking();
env.driveMain();
// If the compiler behaves correctly, it should remember that 'a.html' was updated before,
// and should regenerate a.js. Because the compiler knows a.html is a _resource_ dependency
// of a.ts, it should only regenerate a.js and not its module and dependent components (as
// it would if a.ts were itself changed like in the test above).
expectToHaveWritten([
// Because it directly changed.
'/other.js',
// Because a.html changed
'/a.js',
// module.js should not be re-emitted, as it is not affected by the change and its remote
// scope is unaffected.
// b.js and module.js should not be re-emitted, because specifically when tracking
// resource dependencies, the compiler knows that a change to a resource file only affects
// the direct emit of dependent file.
]);
});
});
});
});
/**
* Two components, ACmp and BCmp, where BCmp depends on ACmp.
*
* ACmp has its template in a separate file.
*/
export function writeTwoComponentSystem(env: NgtscTestEnvironment): void {
env.write('a.html', 'This is the template for CmpA');
env.write('a.ts', `
import {Component} from '@angular/core';
@Component({selector: 'a-cmp', templateUrl: './a.html'})
export class ACmp {}
`);
env.write('b.ts', `
import {Component} from '@angular/core';
@Component({selector: 'b-cmp', template: '<a-cmp></a-cmp>'})
export class BCmp {}
`);
env.write('module.ts', `
import {NgModule} from '@angular/core';
import {ACmp} from './a';
import {BCmp} from './b';
@NgModule({
declarations: [ACmp, BCmp],
})
export class Module {}
`);
}
export function writeRandomFile(
env: NgtscTestEnvironment, name: string, options: {error?: true} = {}): void {
env.write(name, `
// If options.error is set, this class has missing braces.
export class Other ${options.error !== true ? '{}' : ''}
`);
}