This commit performs a modularization of the Language Service's existing diagnostic messages. Such a modularization has two primary advantages: - Centralization and decoupling of error messages from the code that generates them makes it easy to add/delete/edit diagnostic messages, and allows for independent iteration of diagnostic messages and diagnostic generation. - Prepares for additional features like annotating the locations where a diagnostic is generated and enabling the configuration of which diagnostics should be reported by the language service. Although it would be preferable to place the diagnostics registry in an independent JSON file, for ease of typing diagnostic types as an enum variant of 'ts.DiagnosticCategory', the registry is stored as an object. Part of #32663. PR Close #35678
258 lines
10 KiB
TypeScript
258 lines
10 KiB
TypeScript
/**
|
|
* @license
|
|
* Copyright Google Inc. 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 {StaticSymbol} from '@angular/compiler';
|
|
import {Directory} from '@angular/compiler-cli/test/mocks';
|
|
import {ReflectorHost} from '@angular/language-service/src/reflector_host';
|
|
import * as ts from 'typescript';
|
|
|
|
import {getTemplateExpressionDiagnostics} from '../src/expression_diagnostics';
|
|
|
|
import {DiagnosticContext, MockLanguageServiceHost, getDiagnosticTemplateInfo} from './mocks';
|
|
|
|
describe('expression diagnostics', () => {
|
|
let registry: ts.DocumentRegistry;
|
|
|
|
let host: MockLanguageServiceHost;
|
|
let service: ts.LanguageService;
|
|
let context: DiagnosticContext;
|
|
let type: StaticSymbol;
|
|
|
|
beforeAll(() => {
|
|
registry = ts.createDocumentRegistry(false, '/src');
|
|
host = new MockLanguageServiceHost(['app/app.component.ts'], FILES, '/src');
|
|
service = ts.createLanguageService(host, registry);
|
|
const program = service.getProgram() !;
|
|
const checker = program.getTypeChecker();
|
|
const symbolResolverHost = new ReflectorHost(() => program !, host);
|
|
context = new DiagnosticContext(service, program !, checker, symbolResolverHost);
|
|
type = context.getStaticSymbol('app/app.component.ts', 'AppComponent');
|
|
});
|
|
|
|
it('should have no diagnostics in default app', () => {
|
|
function messageToString(messageText: string | ts.DiagnosticMessageChain): string {
|
|
if (typeof messageText == 'string') {
|
|
return messageText;
|
|
} else {
|
|
if (messageText.next)
|
|
return messageText.messageText + messageText.next.map(messageToString);
|
|
return messageText.messageText;
|
|
}
|
|
}
|
|
|
|
function expectNoDiagnostics(diagnostics: ts.Diagnostic[]) {
|
|
if (diagnostics && diagnostics.length) {
|
|
const message =
|
|
'messages: ' + diagnostics.map(d => messageToString(d.messageText)).join('\n');
|
|
expect(message).toEqual('');
|
|
}
|
|
}
|
|
|
|
expectNoDiagnostics(service.getCompilerOptionsDiagnostics());
|
|
expectNoDiagnostics(service.getSyntacticDiagnostics('app/app.component.ts'));
|
|
expectNoDiagnostics(service.getSemanticDiagnostics('app/app.component.ts'));
|
|
});
|
|
|
|
|
|
function accept(template: string) {
|
|
const info = getDiagnosticTemplateInfo(context, type, 'app/app.component.html', template);
|
|
if (info) {
|
|
const diagnostics = getTemplateExpressionDiagnostics(info);
|
|
if (diagnostics && diagnostics.length) {
|
|
const message = diagnostics.map(d => d.message).join('\n ');
|
|
throw new Error(`Unexpected diagnostics: ${message}`);
|
|
}
|
|
} else {
|
|
expect(info).toBeDefined();
|
|
}
|
|
}
|
|
|
|
function reject(template: string, expected: string) {
|
|
const info = getDiagnosticTemplateInfo(context, type, 'app/app.component.html', template);
|
|
if (info) {
|
|
const diagnostics = getTemplateExpressionDiagnostics(info);
|
|
if (diagnostics && diagnostics.length) {
|
|
const messages = diagnostics.map(d => d.message).join('\n ');
|
|
expect(messages).toContain(expected);
|
|
} else {
|
|
throw new Error(`Expected an error containing "${expected} in template "${template}"`);
|
|
}
|
|
} else {
|
|
expect(info).toBeDefined();
|
|
}
|
|
}
|
|
|
|
it('should accept a simple template', () => accept('App works!'));
|
|
it('should accept an interpolation', () => accept('App works: {{person.name.first}}'));
|
|
it('should reject misspelled access',
|
|
() => reject('{{persson}}', 'Identifier \'persson\' is not defined'));
|
|
it('should reject access to private',
|
|
() =>
|
|
reject('{{private_person}}', 'Identifier \'private_person\' refers to a private member'));
|
|
it('should accept an *ngIf', () => accept('<div *ngIf="person">{{person.name.first}}</div>'));
|
|
it('should reject *ngIf of misspelled identifier',
|
|
() => reject(
|
|
'<div *ngIf="persson">{{person.name.first}}</div>',
|
|
'Identifier \'persson\' is not defined'));
|
|
it('should reject *ngIf of misspelled identifier in PrefixNot node',
|
|
() =>
|
|
reject('<div *ngIf="people && !persson"></div>', 'Identifier \'persson\' is not defined'));
|
|
it('should accept an *ngFor', () => accept(`
|
|
<div *ngFor="let p of people">
|
|
{{p.name.first}} {{p.name.last}}
|
|
</div>
|
|
`));
|
|
it('should reject misspelled field in *ngFor', () => reject(
|
|
`
|
|
<div *ngFor="let p of people">
|
|
{{p.names.first}} {{p.name.last}}
|
|
</div>
|
|
`,
|
|
'Identifier \'names\' is not defined'));
|
|
it('should accept an async expression',
|
|
() => accept('{{(promised_person | async)?.name.first || ""}}'));
|
|
it('should reject an async misspelled field',
|
|
() => reject(
|
|
'{{(promised_person | async)?.nume.first || ""}}', 'Identifier \'nume\' is not defined'));
|
|
it('should accept an async *ngFor', () => accept(`
|
|
<div *ngFor="let p of promised_people | async">
|
|
{{p.name.first}} {{p.name.last}}
|
|
</div>
|
|
`));
|
|
it('should reject misspelled field an async *ngFor', () => reject(
|
|
`
|
|
<div *ngFor="let p of promised_people | async">
|
|
{{p.name.first}} {{p.nume.last}}
|
|
</div>
|
|
`,
|
|
'Identifier \'nume\' is not defined'));
|
|
it('should accept an async *ngIf', () => accept(`
|
|
<div *ngIf="promised_person | async as p">
|
|
{{p.name.first}} {{p.name.last}}
|
|
</div>
|
|
`));
|
|
it('should reject misspelled field in async *ngIf', () => reject(
|
|
`
|
|
<div *ngIf="promised_person | async as p">
|
|
{{p.name.first}} {{p.nume.last}}
|
|
</div>
|
|
`,
|
|
'Identifier \'nume\' is not defined'));
|
|
it('should reject access to potentially undefined field',
|
|
() => reject(
|
|
`<div>{{maybe_person.name.first}}`,
|
|
`'maybe_person' is possibly undefined. Consider using the safe navigation operator (maybe_person?.name) or non-null assertion operator (maybe_person!.name).`));
|
|
it('should accept a safe accss to an undefined field',
|
|
() => accept(`<div>{{maybe_person?.name.first}}</div>`));
|
|
it('should accept a type assert to an undefined field',
|
|
() => accept(`<div>{{maybe_person!.name.first}}</div>`));
|
|
it('should accept a # reference', () => accept(`
|
|
<form #f="ngForm" novalidate>
|
|
<input name="first" ngModel required #first="ngModel">
|
|
<input name="last" ngModel>
|
|
<button>Submit</button>
|
|
</form>
|
|
<p>First name value: {{ first.value }}</p>
|
|
<p>First name valid: {{ first.valid }}</p>
|
|
<p>Form value: {{ f.value | json }}</p>
|
|
<p>Form valid: {{ f.valid }}</p>
|
|
`));
|
|
it('should reject a misspelled field of a # reference',
|
|
() => reject(
|
|
`
|
|
<form #f="ngForm" novalidate>
|
|
<input name="first" ngModel required #first="ngModel">
|
|
<input name="last" ngModel>
|
|
<button>Submit</button>
|
|
</form>
|
|
<p>First name value: {{ first.valwe }}</p>
|
|
<p>First name valid: {{ first.valid }}</p>
|
|
<p>Form value: {{ f.value | json }}</p>
|
|
<p>Form valid: {{ f.valid }}</p>
|
|
`,
|
|
'Identifier \'valwe\' is not defined'));
|
|
it('should accept a call to a method', () => accept('{{getPerson().name.first}}'));
|
|
it('should reject a misspelled field of a method result',
|
|
() => reject('{{getPerson().nume.first}}', 'Identifier \'nume\' is not defined'));
|
|
it('should reject calling a uncallable member',
|
|
() => reject('{{person().name.first}}', '\'person\' is not callable'));
|
|
it('should accept an event handler',
|
|
() => accept('<div (click)="click($event)">{{person.name.first}}</div>'));
|
|
it('should reject a misspelled event handler',
|
|
() => reject(
|
|
'<div (click)="clack($event)">{{person.name.first}}</div>',
|
|
`Identifier 'clack' is not defined. The component declaration, template variable declarations, and element references do not contain such a member`));
|
|
it('should reject an uncalled event handler',
|
|
() => reject(
|
|
'<div (click)="click">{{person.name.first}}</div>', 'Unexpected callable expression'));
|
|
describe('with comparisons between nullable and non-nullable', () => {
|
|
it('should accept ==', () => accept(`<div>{{e == 1 ? 'a' : 'b'}}</div>`));
|
|
it('should accept ===', () => accept(`<div>{{e === 1 ? 'a' : 'b'}}</div>`));
|
|
it('should accept !=', () => accept(`<div>{{e != 1 ? 'a' : 'b'}}</div>`));
|
|
it('should accept !==', () => accept(`<div>{{e !== 1 ? 'a' : 'b'}}</div>`));
|
|
it('should accept &&', () => accept(`<div>{{e && 1 ? 'a' : 'b'}}</div>`));
|
|
it('should accept ||', () => accept(`<div>{{e || 1 ? 'a' : 'b'}}</div>`));
|
|
it('should reject >',
|
|
() => reject(`<div>{{e > 1 ? 'a' : 'b'}}</div>`, 'The expression might be null'));
|
|
});
|
|
});
|
|
|
|
const FILES: Directory = {
|
|
'src': {
|
|
'app': {
|
|
'app.component.ts': `
|
|
import { Component, NgModule } from '@angular/core';
|
|
import { CommonModule } from '@angular/common';
|
|
import { FormsModule } from '@angular/forms';
|
|
|
|
export interface Person {
|
|
name: Name;
|
|
address: Address;
|
|
}
|
|
|
|
export interface Name {
|
|
first: string;
|
|
middle: string;
|
|
last: string;
|
|
}
|
|
|
|
export interface Address {
|
|
street: string;
|
|
city: string;
|
|
state: string;
|
|
zip: string;
|
|
}
|
|
|
|
@Component({
|
|
selector: 'my-app',
|
|
templateUrl: './app.component.html'
|
|
})
|
|
export class AppComponent {
|
|
person: Person;
|
|
people: Person[];
|
|
maybe_person?: Person;
|
|
promised_person: Promise<Person>;
|
|
promised_people: Promise<Person[]>;
|
|
private private_person: Person;
|
|
private private_people: Person[];
|
|
e?: number;
|
|
|
|
getPerson(): Person { return this.person; }
|
|
click() {}
|
|
}
|
|
|
|
@NgModule({
|
|
imports: [CommonModule, FormsModule],
|
|
declarations: [AppComponent]
|
|
})
|
|
export class AppModule {}
|
|
`
|
|
}
|
|
}
|
|
};
|