feat(compiler): reuse the TypeScript typecheck for template typechecking. (#19152)
This speeds up the compilation process significantly. Also introduces a new option `fullTemplateTypeCheck` to do more checks in templates: - check expressions inside of templatized content (e.g. inside of `<div *ngIf>`). - check the arguments of calls to the `transform` function of pipes - check references to directives that were exposed as variables via `exportAs` PR Close #19152
This commit is contained in:

committed by
Matias Niemelä

parent
554fe65690
commit
996c7c2dde
@ -6,95 +6,200 @@
|
||||
* found in the LICENSE file at https://angular.io/license
|
||||
*/
|
||||
|
||||
import {AotCompilerOptions, createAotCompiler} from '@angular/compiler';
|
||||
import {EmittingCompilerHost, MockAotCompilerHost, MockCompilerHost, MockData, MockDirectory, MockMetadataBundlerHost, arrayToMockDir, arrayToMockMap, isSource, settings, setup, toMockFileArray} from '@angular/compiler/test/aot/test_util';
|
||||
import * as ng from '@angular/compiler-cli';
|
||||
import {makeTempDir} from '@angular/tsc-wrapped/test/test_support';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as ts from 'typescript';
|
||||
|
||||
import {TypeChecker} from '../../src/diagnostics/check_types';
|
||||
import {Diagnostic} from '../../src/transformers/api';
|
||||
import {LowerMetadataCache} from '../../src/transformers/lower_expressions';
|
||||
|
||||
function compile(
|
||||
rootDirs: MockData, options: AotCompilerOptions = {},
|
||||
tsOptions: ts.CompilerOptions = {}): Diagnostic[] {
|
||||
const rootDirArr = toMockFileArray(rootDirs);
|
||||
const scriptNames = rootDirArr.map(entry => entry.fileName).filter(isSource);
|
||||
const host = new MockCompilerHost(scriptNames, arrayToMockDir(rootDirArr));
|
||||
const aotHost = new MockAotCompilerHost(host, new LowerMetadataCache({}));
|
||||
const tsSettings = {...settings, ...tsOptions};
|
||||
const program = ts.createProgram(host.scriptNames.slice(0), tsSettings, host);
|
||||
const ngChecker = new TypeChecker(program, tsSettings, host, aotHost, options);
|
||||
return ngChecker.getDiagnostics();
|
||||
function getNgRootDir() {
|
||||
const moduleFilename = module.filename.replace(/\\/g, '/');
|
||||
const distIndex = moduleFilename.indexOf('/dist/all');
|
||||
return moduleFilename.substr(0, distIndex);
|
||||
}
|
||||
|
||||
describe('ng type checker', () => {
|
||||
let angularFiles = setup();
|
||||
let basePath: string;
|
||||
let write: (fileName: string, content: string) => void;
|
||||
let errorSpy: jasmine.Spy&((s: string) => void);
|
||||
|
||||
function accept(...files: MockDirectory[]) {
|
||||
expectNoDiagnostics(compile([angularFiles, QUICKSTART, ...files]));
|
||||
function compileAndCheck(
|
||||
mockDirs: {[fileName: string]: string}[],
|
||||
overrideOptions: ng.CompilerOptions = {}): ng.Diagnostics {
|
||||
const fileNames: string[] = [];
|
||||
mockDirs.forEach((dir) => {
|
||||
Object.keys(dir).forEach((fileName) => {
|
||||
if (fileName.endsWith('.ts')) {
|
||||
fileNames.push(path.resolve(basePath, fileName));
|
||||
}
|
||||
write(fileName, dir[fileName]);
|
||||
});
|
||||
});
|
||||
const options: ng.CompilerOptions = {
|
||||
basePath,
|
||||
'experimentalDecorators': true,
|
||||
'skipLibCheck': true,
|
||||
'strict': true,
|
||||
'types': [],
|
||||
'outDir': path.resolve(basePath, 'built'),
|
||||
'rootDir': basePath,
|
||||
'baseUrl': basePath,
|
||||
'declaration': true,
|
||||
'target': ts.ScriptTarget.ES5,
|
||||
'module': ts.ModuleKind.ES2015,
|
||||
'moduleResolution': ts.ModuleResolutionKind.NodeJs,
|
||||
'lib': [
|
||||
path.resolve(basePath, 'node_modules/typescript/lib/lib.es6.d.ts'),
|
||||
path.resolve(basePath, 'node_modules/typescript/lib/lib.dom.d.ts')
|
||||
],
|
||||
'typeRoots': [path.resolve(basePath, 'node_modules/@types')], ...overrideOptions
|
||||
};
|
||||
const {diagnostics} = ng.performCompilation({rootNames: fileNames, options});
|
||||
return diagnostics;
|
||||
}
|
||||
|
||||
function reject(message: string | RegExp, ...files: MockDirectory[]) {
|
||||
const diagnostics = compile([angularFiles, QUICKSTART, ...files]);
|
||||
beforeEach(() => {
|
||||
errorSpy = jasmine.createSpy('consoleError').and.callFake(console.error);
|
||||
basePath = makeTempDir();
|
||||
write = (fileName: string, content: string) => {
|
||||
const dir = path.dirname(fileName);
|
||||
if (dir != '.') {
|
||||
const newDir = path.join(basePath, dir);
|
||||
if (!fs.existsSync(newDir)) fs.mkdirSync(newDir);
|
||||
}
|
||||
fs.writeFileSync(path.join(basePath, fileName), content, {encoding: 'utf-8'});
|
||||
};
|
||||
const ngRootDir = getNgRootDir();
|
||||
const nodeModulesPath = path.resolve(basePath, 'node_modules');
|
||||
fs.mkdirSync(nodeModulesPath);
|
||||
fs.symlinkSync(
|
||||
path.resolve(ngRootDir, 'dist', 'all', '@angular'),
|
||||
path.resolve(nodeModulesPath, '@angular'));
|
||||
fs.symlinkSync(
|
||||
path.resolve(ngRootDir, 'node_modules', 'rxjs'), path.resolve(nodeModulesPath, 'rxjs'));
|
||||
fs.symlinkSync(
|
||||
path.resolve(ngRootDir, 'node_modules', 'typescript'),
|
||||
path.resolve(nodeModulesPath, 'typescript'));
|
||||
});
|
||||
|
||||
function accept(
|
||||
files: {[fileName: string]: string} = {}, overrideOptions: ng.CompilerOptions = {}) {
|
||||
expectNoDiagnostics(compileAndCheck([QUICKSTART, files], overrideOptions));
|
||||
}
|
||||
|
||||
function reject(
|
||||
message: string | RegExp, location: RegExp, files: {[fileName: string]: string},
|
||||
overrideOptions: ng.CompilerOptions = {}) {
|
||||
const diagnostics = compileAndCheck([QUICKSTART, files], overrideOptions);
|
||||
if (!diagnostics || !diagnostics.length) {
|
||||
throw new Error('Expected a diagnostic erorr message');
|
||||
} else {
|
||||
const matches: (d: Diagnostic) => boolean = typeof message === 'string' ?
|
||||
d => d.messageText == message :
|
||||
d => message.test(d.messageText);
|
||||
const matchingDiagnostics = diagnostics.filter(matches);
|
||||
const matches: (d: ng.Diagnostic) => boolean = typeof message === 'string' ?
|
||||
d => ng.isNgDiagnostic(d)&& d.messageText == message :
|
||||
d => ng.isNgDiagnostic(d) && message.test(d.messageText);
|
||||
const matchingDiagnostics = diagnostics.filter(matches) as ng.Diagnostic[];
|
||||
if (!matchingDiagnostics || !matchingDiagnostics.length) {
|
||||
throw new Error(
|
||||
`Expected a diagnostics matching ${message}, received\n ${diagnostics.map(d => d.messageText).join('\n ')}`);
|
||||
}
|
||||
|
||||
const span = matchingDiagnostics[0].span;
|
||||
if (!span) {
|
||||
throw new Error('Expected a sourceSpan');
|
||||
}
|
||||
expect(`${span.start.file.url}@${span.start.line}:${span.start.offset}`).toMatch(location);
|
||||
}
|
||||
}
|
||||
|
||||
it('should accept unmodified QuickStart', () => { accept(); });
|
||||
|
||||
describe('with modified quickstart', () => {
|
||||
function a(template: string) {
|
||||
accept({quickstart: {app: {'app.component.ts': appComponentSource(template)}}});
|
||||
it('should accept unmodified QuickStart with tests for unused variables', () => {
|
||||
accept({}, {
|
||||
strict: true,
|
||||
noUnusedLocals: true,
|
||||
noUnusedParameters: true,
|
||||
});
|
||||
});
|
||||
|
||||
describe('with modified quickstart (fullTemplateTypeCheck: false)', () => {
|
||||
addTests({fullTemplateTypeCheck: false});
|
||||
});
|
||||
|
||||
describe('with modified quickstart (fullTemplateTypeCheck: true)', () => {
|
||||
addTests({fullTemplateTypeCheck: true});
|
||||
});
|
||||
|
||||
function addTests(config: {fullTemplateTypeCheck: boolean}) {
|
||||
function a(template: string) { accept({'src/app.component.html': template}, config); }
|
||||
|
||||
function r(template: string, message: string | RegExp, location: string) {
|
||||
reject(
|
||||
message, new RegExp(`app\.component\.html\@${location}$`),
|
||||
{'src/app.component.html': template}, config);
|
||||
}
|
||||
|
||||
function r(template: string, message: string | RegExp) {
|
||||
reject(message, {quickstart: {app: {'app.component.ts': appComponentSource(template)}}});
|
||||
function rejectOnlyWithFullTemplateTypeCheck(
|
||||
template: string, message: string | RegExp, location: string) {
|
||||
if (config.fullTemplateTypeCheck) {
|
||||
r(template, message, location);
|
||||
} else {
|
||||
a(template);
|
||||
}
|
||||
}
|
||||
|
||||
it('should report an invalid field access',
|
||||
() => { r('{{fame}}', `Property 'fame' does not exist on type 'AppComponent'.`); });
|
||||
it('should report an invalid field access', () => {
|
||||
r('<div>{{fame}}<div>', `Property 'fame' does not exist on type 'AppComponent'.`, '0:5');
|
||||
});
|
||||
it('should reject a reference to a field of a nullable',
|
||||
() => { r('{{maybePerson.name}}', `Object is possibly 'undefined'.`); });
|
||||
() => { r('<div>{{maybePerson.name}}</div>', `Object is possibly 'undefined'.`, '0:5'); });
|
||||
it('should accept a reference to a field of a nullable using using non-null-assert',
|
||||
() => { a('{{maybePerson!.name}}'); });
|
||||
it('should accept a safe property access of a nullable person',
|
||||
() => { a('{{maybePerson?.name}}'); });
|
||||
it('should accept a function call', () => { a('{{getName()}}'); });
|
||||
it('should reject an invalid method', () => {
|
||||
r('{{getFame()}}',
|
||||
`Property 'getFame' does not exist on type 'AppComponent'. Did you mean 'getName'?`);
|
||||
r('<div>{{getFame()}}</div>',
|
||||
`Property 'getFame' does not exist on type 'AppComponent'. Did you mean 'getName'?`, '0:5');
|
||||
});
|
||||
it('should accept a field access of a method result', () => { a('{{getPerson().name}}'); });
|
||||
it('should reject an invalid field reference of a method result',
|
||||
() => { r('{{getPerson().fame}}', `Property 'fame' does not exist on type 'Person'.`); });
|
||||
it('should reject an access to a nullable field of a method result',
|
||||
() => { r('{{getMaybePerson().name}}', `Object is possibly 'undefined'.`); });
|
||||
it('should reject an invalid field reference of a method result', () => {
|
||||
r('<div>{{getPerson().fame}}</div>', `Property 'fame' does not exist on type 'Person'.`,
|
||||
'0:5');
|
||||
});
|
||||
it('should reject an access to a nullable field of a method result', () => {
|
||||
r('<div>{{getMaybePerson().name}}</div>', `Object is possibly 'undefined'.`, '0:5');
|
||||
});
|
||||
it('should accept a nullable assert of a nullable field refernces of a method result',
|
||||
() => { a('{{getMaybePerson()!.name}}'); });
|
||||
it('should accept a safe property access of a nullable field reference of a method result',
|
||||
() => { a('{{getMaybePerson()?.name}}'); });
|
||||
});
|
||||
|
||||
it('should report an invalid field access inside of an ng-template', () => {
|
||||
rejectOnlyWithFullTemplateTypeCheck(
|
||||
'<ng-template>{{fame}}</ng-template>',
|
||||
`Property 'fame' does not exist on type 'AppComponent'.`, '0:13');
|
||||
});
|
||||
it('should report an invalid call to a pipe', () => {
|
||||
rejectOnlyWithFullTemplateTypeCheck(
|
||||
'<div>{{"hello" | aPipe}}</div>',
|
||||
`Argument of type '"hello"' is not assignable to parameter of type 'number'.`, '0:5');
|
||||
});
|
||||
it('should report an invalid property on an exportAs directive', () => {
|
||||
rejectOnlyWithFullTemplateTypeCheck(
|
||||
'<div aDir #aDir="aDir">{{aDir.fname}}</div>',
|
||||
`Property 'fname' does not exist on type 'ADirective'. Did you mean 'name'?`, '0:23');
|
||||
});
|
||||
}
|
||||
|
||||
describe('with lowered expressions', () => {
|
||||
it('should not report lowered expressions as errors', () => {
|
||||
expectNoDiagnostics(compile([angularFiles, LOWERING_QUICKSTART]));
|
||||
});
|
||||
it('should not report lowered expressions as errors',
|
||||
() => { expectNoDiagnostics(compileAndCheck([LOWERING_QUICKSTART])); });
|
||||
});
|
||||
});
|
||||
|
||||
function appComponentSource(template: string): string {
|
||||
function appComponentSource(): string {
|
||||
return `
|
||||
import {Component} from '@angular/core';
|
||||
import {Component, Pipe, Directive} from '@angular/core';
|
||||
|
||||
export interface Person {
|
||||
name: string;
|
||||
@ -109,7 +214,7 @@ function appComponentSource(template: string): string {
|
||||
}
|
||||
|
||||
@Component({
|
||||
template: '${template}'
|
||||
templateUrl: './app.component.html'
|
||||
})
|
||||
export class AppComponent {
|
||||
name = 'Angular';
|
||||
@ -119,63 +224,69 @@ function appComponentSource(template: string): string {
|
||||
|
||||
getName(): string { return this.name; }
|
||||
getPerson(): Person { return this.person; }
|
||||
getMaybePerson(): Person | undefined { this.maybePerson; }
|
||||
getMaybePerson(): Person | undefined { return this.maybePerson; }
|
||||
}
|
||||
|
||||
@Pipe({
|
||||
name: 'aPipe',
|
||||
})
|
||||
export class APipe {
|
||||
transform(n: number): number { return n + 1; }
|
||||
}
|
||||
|
||||
@Directive({
|
||||
selector: '[aDir]',
|
||||
exportAs: 'aDir'
|
||||
})
|
||||
export class ADirective {
|
||||
name = 'ADirective';
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
const QUICKSTART: MockDirectory = {
|
||||
quickstart: {
|
||||
app: {
|
||||
'app.component.ts': appComponentSource('<h1>Hello {{name}}</h1>'),
|
||||
'app.module.ts': `
|
||||
import { NgModule } from '@angular/core';
|
||||
import { toString } from './utils';
|
||||
const QUICKSTART = {
|
||||
'src/app.component.ts': appComponentSource(),
|
||||
'src/app.component.html': '<h1>Hello {{name}}</h1>',
|
||||
'src/app.module.ts': `
|
||||
import { NgModule } from '@angular/core';
|
||||
import { AppComponent, APipe, ADirective } from './app.component';
|
||||
|
||||
import { AppComponent } from './app.component';
|
||||
|
||||
@NgModule({
|
||||
declarations: [ AppComponent ],
|
||||
bootstrap: [ AppComponent ]
|
||||
})
|
||||
export class AppModule { }
|
||||
`
|
||||
}
|
||||
}
|
||||
@NgModule({
|
||||
declarations: [ AppComponent, APipe, ADirective ],
|
||||
bootstrap: [ AppComponent ]
|
||||
})
|
||||
export class AppModule { }
|
||||
`
|
||||
};
|
||||
|
||||
const LOWERING_QUICKSTART: MockDirectory = {
|
||||
quickstart: {
|
||||
app: {
|
||||
'app.component.ts': appComponentSource('<h1>Hello {{name}}</h1>'),
|
||||
'app.module.ts': `
|
||||
import { NgModule, Component } from '@angular/core';
|
||||
import { toString } from './utils';
|
||||
const LOWERING_QUICKSTART = {
|
||||
'src/app.component.ts': appComponentSource(),
|
||||
'src/app.component.html': '<h1>Hello {{name}}</h1>',
|
||||
'src/app.module.ts': `
|
||||
import { NgModule, Component } from '@angular/core';
|
||||
|
||||
import { AppComponent } from './app.component';
|
||||
import { AppComponent, APipe, ADirective } from './app.component';
|
||||
|
||||
class Foo {}
|
||||
class Foo {}
|
||||
|
||||
@Component({
|
||||
template: '',
|
||||
providers: [
|
||||
{provide: 'someToken', useFactory: () => new Foo()}
|
||||
]
|
||||
})
|
||||
export class Bar {}
|
||||
@Component({
|
||||
template: '',
|
||||
providers: [
|
||||
{provide: 'someToken', useFactory: () => new Foo()}
|
||||
]
|
||||
})
|
||||
export class Bar {}
|
||||
|
||||
@NgModule({
|
||||
declarations: [ AppComponent, Bar ],
|
||||
bootstrap: [ AppComponent ]
|
||||
})
|
||||
export class AppModule { }
|
||||
`
|
||||
}
|
||||
}
|
||||
@NgModule({
|
||||
declarations: [ AppComponent, APipe, ADirective, Bar ],
|
||||
bootstrap: [ AppComponent ]
|
||||
})
|
||||
export class AppModule { }
|
||||
`
|
||||
};
|
||||
|
||||
function expectNoDiagnostics(diagnostics: Diagnostic[]) {
|
||||
function expectNoDiagnostics(diagnostics: ng.Diagnostics) {
|
||||
if (diagnostics && diagnostics.length) {
|
||||
throw new Error(diagnostics.map(d => `${d.span}: ${d.messageText}`).join('\n'));
|
||||
throw new Error(ng.formatDiagnostics({}, diagnostics));
|
||||
}
|
||||
}
|
||||
|
Reference in New Issue
Block a user