Files
Paul Gschwendtner 627d0070e3 refactor: update language-service package and tests to work with ESM (#48538)
The Angular Language Service package is now tested & compiled using ESM.
Previously it was a mismatch of CommonJS and ESM.

Still the language-service ESM output is transformed into a single UMD
bundle to allow for module resolution to be overriden. See
`bundles/BUILD`.

This is kept as is. To fully ship ESM (language-service is an exception
here), we need to:

 * Update all code to no longer reference typescript via import. Instead
   typescript needs to be passed around so that the extension can
   control the version
 * The VSCode extension/ the TS server needs to be able to load ESM. It
   looks like the server supports ESM global plugins, but the extension
   might not yet.

This is out of scope for the dev-infra effort as it requires more
insight into VSCode & the extension system & the TS language server.

PR Close #48538
2022-12-19 20:12:37 +00:00

704 lines
25 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 {initMockFileSystem} from '@angular/compiler-cli/src/ngtsc/file_system/testing';
import ts from 'typescript/lib/tsserverlibrary';
import {createModuleAndProjectWithDeclarations, LanguageServiceTestEnv, Project} from '../testing';
function quickInfoSkeleton(): {[fileName: string]: string} {
return {
'app.ts': `
import {Component, Directive, EventEmitter, Input, NgModule, Output, Pipe, PipeTransform} from '@angular/core';
import {CommonModule} from '@angular/common';
export interface Address {
streetName: string;
}
/** The most heroic being. */
export interface Hero {
id: number;
name: string;
address?: Address;
}
/**
* This Component provides the \`test-comp\` selector.
*/
/*BeginTestComponent*/ @Component({
selector: 'test-comp',
template: '<div>Testing: {{name}}</div>',
})
export class TestComponent {
@Input('tcName') name!: string;
@Output('test') testEvent!: EventEmitter<string>;
} /*EndTestComponent*/
@Component({
selector: 'app-cmp',
templateUrl: './app.html',
})
export class AppCmp {
hero!: Hero;
heroes!: Hero[];
readonlyHeroes!: ReadonlyArray<Readonly<Hero>>;
/**
* This is the title of the \`AppCmp\` Component.
*/
title!: string;
constNames!: [{readonly name: 'name'}];
constNamesOptional?: [{readonly name: 'name'}];
birthday!: Date;
anyValue!: any;
myClick(event: any) {}
setTitle(newTitle: string) {}
trackByFn!: any;
name!: any;
}
@Directive({
selector: '[string-model]',
exportAs: 'stringModel',
})
export class StringModel {
@Input() model!: string;
@Output() modelChange!: EventEmitter<string>;
}
@Directive({selector: 'button[custom-button][compound]'})
export class CompoundCustomButtonDirective {
@Input() config?: {color?: string};
}
@NgModule({
declarations: [
AppCmp,
CompoundCustomButtonDirective,
StringModel,
TestComponent,
],
imports: [
CommonModule,
],
})
export class AppModule {}
`,
'app.html': `Will be overridden`,
};
}
describe('quick info', () => {
let env: LanguageServiceTestEnv;
let project: Project;
describe('strict templates (happy path)', () => {
beforeEach(() => {
initMockFileSystem('Native');
env = LanguageServiceTestEnv.setup();
project = env.addProject('test', quickInfoSkeleton());
});
describe('elements', () => {
it('should work for native elements', () => {
expectQuickInfo({
templateOverride: `<butt¦on></button>`,
expectedSpanText: '<button></button>',
expectedDisplayString: '(element) button: HTMLButtonElement'
});
});
it('should work for directives which match native element tags', () => {
expectQuickInfo({
templateOverride: `<butt¦on compound custom-button></button>`,
expectedSpanText: '<button compound custom-button></button>',
expectedDisplayString: '(directive) AppModule.CompoundCustomButtonDirective'
});
});
});
describe('templates', () => {
it('should return undefined for ng-templates', () => {
const {documentation} = expectQuickInfo({
templateOverride: `<ng-templ¦ate></ng-template>`,
expectedSpanText: '<ng-template></ng-template>',
expectedDisplayString: '(template) ng-template'
});
expect(toText(documentation))
.toContain('The `<ng-template>` is an Angular element for rendering HTML.');
});
});
describe('directives', () => {
it('should work for directives', () => {
expectQuickInfo({
templateOverride: `<div string-model¦></div>`,
expectedSpanText: 'string-model',
expectedDisplayString: '(directive) AppModule.StringModel'
});
});
it('should work for components', () => {
const {documentation} = expectQuickInfo({
templateOverride: `<t¦est-comp></test-comp>`,
expectedSpanText: '<test-comp></test-comp>',
expectedDisplayString: '(component) AppModule.TestComponent'
});
expect(toText(documentation)).toBe('This Component provides the `test-comp` selector.');
});
it('should work for components with bound attributes', () => {
const {documentation} = expectQuickInfo({
templateOverride: `<t¦est-comp [attr.id]="'1' + '2'" [attr.name]="'myName'"></test-comp>`,
expectedSpanText: `<test-comp [attr.id]="'1' + '2'" [attr.name]="'myName'"></test-comp>`,
expectedDisplayString: '(component) AppModule.TestComponent'
});
expect(toText(documentation)).toBe('This Component provides the `test-comp` selector.');
});
it('should work for structural directives', () => {
const {documentation} = expectQuickInfo({
templateOverride: `<div *¦ngFor="let item of heroes"></div>`,
expectedSpanText: 'ngFor',
expectedDisplayString: '(directive) NgForOf<Hero, Hero[]>'
});
expect(toText(documentation)).toContain('A fake version of the NgFor directive.');
});
it('should work for directives with compound selectors, some of which are bindings', () => {
expectQuickInfo({
templateOverride:
`<ng-template ngF¦or let-hero [ngForOf]="heroes">{{hero}}</ng-template>`,
expectedSpanText: 'ngFor',
expectedDisplayString: '(directive) NgForOf<Hero, Hero[]>'
});
});
it('should work for data-let- syntax', () => {
expectQuickInfo({
templateOverride:
`<ng-template ngFor data-let-he¦ro [ngForOf]="heroes">{{hero}}</ng-template>`,
expectedSpanText: 'hero',
expectedDisplayString: '(variable) hero: Hero'
});
});
});
describe('bindings', () => {
describe('inputs', () => {
it('should work for input providers', () => {
expectQuickInfo({
templateOverride: `<test-comp [tcN¦ame]="name"></test-comp>`,
expectedSpanText: 'tcName',
expectedDisplayString: '(property) TestComponent.name: string'
});
});
it('should work for bind- syntax', () => {
expectQuickInfo({
templateOverride: `<test-comp bind-tcN¦ame="name"></test-comp>`,
expectedSpanText: 'tcName',
expectedDisplayString: '(property) TestComponent.name: string'
});
expectQuickInfo({
templateOverride: `<test-comp data-bind-tcN¦ame="name"></test-comp>`,
expectedSpanText: 'tcName',
expectedDisplayString: '(property) TestComponent.name: string'
});
});
it('should work for structural directive inputs ngForTrackBy', () => {
expectQuickInfo({
templateOverride: `<div *ngFor="let item of heroes; tr¦ackBy: trackByFn;"></div>`,
expectedSpanText: 'trackBy',
expectedDisplayString:
'(property) NgForOf<Hero, Hero[]>.ngForTrackBy: TrackByFunction<Hero>'
});
});
it('should work for structural directive inputs ngForOf', () => {
expectQuickInfo({
templateOverride: `<div *ngFor="let item o¦f heroes; trackBy: trackByFn;"></div>`,
expectedSpanText: 'of',
expectedDisplayString:
'(property) NgForOf<Hero, Hero[]>.ngForOf: (Hero[] & NgIterable<Hero>) | null | undefined'
});
});
it('should work for two-way binding providers', () => {
expectQuickInfo({
templateOverride: `<test-comp string-model [(mo¦del)]="title"></test-comp>`,
expectedSpanText: 'model',
expectedDisplayString: '(property) StringModel.model: string'
});
});
});
describe('outputs', () => {
it('should work for event providers', () => {
expectQuickInfo({
templateOverride: `<test-comp (te¦st)="myClick($event)"></test-comp>`,
expectedSpanText: 'test',
expectedDisplayString: '(event) TestComponent.testEvent: EventEmitter<string>'
});
});
it('should work for on- syntax binding', () => {
expectQuickInfo({
templateOverride: `<test-comp on-te¦st="myClick($event)"></test-comp>`,
expectedSpanText: 'test',
expectedDisplayString: '(event) TestComponent.testEvent: EventEmitter<string>'
});
expectQuickInfo({
templateOverride: `<test-comp data-on-te¦st="myClick($event)"></test-comp>`,
expectedSpanText: 'test',
expectedDisplayString: '(event) TestComponent.testEvent: EventEmitter<string>'
});
});
it('should work for $event from EventEmitter', () => {
expectQuickInfo({
templateOverride: `<div string-model (modelChange)="myClick($e¦vent)"></div>`,
expectedSpanText: '$event',
expectedDisplayString: '(parameter) $event: string'
});
});
it('should work for $event from native element', () => {
expectQuickInfo({
templateOverride: `<div (click)="myClick($e¦vent)"></div>`,
expectedSpanText: '$event',
expectedDisplayString: '(parameter) $event: MouseEvent'
});
});
});
});
describe('references', () => {
it('should work for element reference declarations', () => {
const {documentation} = expectQuickInfo({
templateOverride: `<div #¦chart></div>`,
expectedSpanText: 'chart',
expectedDisplayString: '(reference) chart: HTMLDivElement'
});
expect(toText(documentation))
.toEqual(
'Provides special properties (beyond the regular HTMLElement ' +
'interface it also has available to it by inheritance) for manipulating <div> elements.');
});
it('should work for directive references', () => {
expectQuickInfo({
templateOverride: `<div string-model #dir¦Ref="stringModel"></div>`,
expectedSpanText: 'dirRef',
expectedDisplayString: '(reference) dirRef: StringModel'
});
});
it('should work for ref- syntax', () => {
expectQuickInfo({
templateOverride: `<div ref-ch¦art></div>`,
expectedSpanText: 'chart',
expectedDisplayString: '(reference) chart: HTMLDivElement'
});
expectQuickInfo({
templateOverride: `<div data-ref-ch¦art></div>`,
expectedSpanText: 'chart',
expectedDisplayString: '(reference) chart: HTMLDivElement'
});
});
it('should work for $event from native element', () => {
expectQuickInfo({
templateOverride: `<div (click)="myClick($e¦vent)"></div>`,
expectedSpanText: '$event',
expectedDisplayString: '(parameter) $event: MouseEvent'
});
});
it('should work for click output from native element', () => {
expectQuickInfo({
templateOverride: `<div (cl¦ick)="myClick($event)"></div>`,
expectedSpanText: 'click',
expectedDisplayString:
'(event) HTMLDivElement.addEventListener<"click">(type: "click", ' +
'listener: (this: HTMLDivElement, ev: MouseEvent) => any, ' +
'options?: boolean | AddEventListenerOptions | undefined): void (+1 overload)'
});
});
});
describe('variables', () => {
it('should work for array members', () => {
const {documentation} = expectQuickInfo({
templateOverride: `<div *ngFor="let hero of heroes">{{her¦o}}</div>`,
expectedSpanText: 'hero',
expectedDisplayString: '(variable) hero: Hero'
});
expect(toText(documentation)).toEqual('The most heroic being.');
});
it('should work for ReadonlyArray members (#36191)', () => {
expectQuickInfo({
templateOverride: `<div *ngFor="let hero of readonlyHeroes">{{her¦o}}</div>`,
expectedSpanText: 'hero',
expectedDisplayString: '(variable) hero: Readonly<Hero>'
});
});
it('should work for const array members (#36191)', () => {
expectQuickInfo({
templateOverride: `<div *ngFor="let name of constNames">{{na¦me}}</div>`,
expectedSpanText: 'name',
expectedDisplayString: '(variable) name: { readonly name: "name"; }'
});
});
it('should work for safe keyed reads', () => {
expectQuickInfo({
templateOverride: `<div>{{constNamesOptional?.[0¦]}}</div>`,
expectedSpanText: '0',
expectedDisplayString: '(property) 0: {\n readonly name: "name";\n}'
});
expectQuickInfo({
templateOverride: `<div>{{constNamesOptional?.[0]?.na¦me}}</div>`,
expectedSpanText: 'constNamesOptional?.[0]?.name',
expectedDisplayString: '(property) name: "name"'
});
});
});
describe('pipes', () => {
it('should work for pipes', () => {
const templateOverride = `<p>The hero's birthday is {{birthday | da¦te: "MM/dd/yy"}}</p>`;
expectQuickInfo({
templateOverride,
expectedSpanText: 'date',
expectedDisplayString:
'(pipe) DatePipe.transform(value: string | number | Date, format?: string | undefined, timezone?: ' +
'string | undefined, locale?: string | undefined): string | null (+2 overloads)'
});
});
});
describe('expressions', () => {
it('should find members in a text interpolation', () => {
expectQuickInfo({
templateOverride: `<div>{{ tit¦le }}</div>`,
expectedSpanText: 'title',
expectedDisplayString: '(property) AppCmp.title: string'
});
});
it('should work for accessed property reads', () => {
expectQuickInfo({
templateOverride: `<div>{{title.len¦gth}}</div>`,
expectedSpanText: 'length',
expectedDisplayString: '(property) String.length: number'
});
});
it('should find members in an attribute interpolation', () => {
expectQuickInfo({
templateOverride: `<div string-model model="{{tit¦le}}"></div>`,
expectedSpanText: 'title',
expectedDisplayString: '(property) AppCmp.title: string'
});
});
it('should find members of input binding', () => {
expectQuickInfo({
templateOverride: `<test-comp [tcName]="ti¦tle"></test-comp>`,
expectedSpanText: 'title',
expectedDisplayString: '(property) AppCmp.title: string'
});
});
it('should find input binding on text attribute', () => {
expectQuickInfo({
templateOverride: `<test-comp tcN¦ame="title"></test-comp>`,
expectedSpanText: 'tcName',
expectedDisplayString: '(property) TestComponent.name: string'
});
});
it('should find members of event binding', () => {
expectQuickInfo({
templateOverride: `<test-comp (test)="ti¦tle=$event"></test-comp>`,
expectedSpanText: 'title',
expectedDisplayString: '(property) AppCmp.title: string'
});
});
it('should work for method calls', () => {
expectQuickInfo({
templateOverride: `<div (click)="setT¦itle('title')"></div>`,
expectedSpanText: 'setTitle',
expectedDisplayString: '(method) AppCmp.setTitle(newTitle: string): void'
});
});
it('should work for safe method calls', () => {
const files = {
'app.ts': `import {Component} from '@angular/core';
@Component({template: '<div (click)="something?.myFunc()"></div>'})
export class AppCmp {
something!: {
/** Documentation for myFunc. */
myFunc(): void
};
}`,
};
const project = createModuleAndProjectWithDeclarations(env, 'test_project', files);
const appFile = project.openFile('app.ts');
appFile.moveCursorToText('something?.myF¦unc()');
const info = appFile.getQuickInfoAtPosition()!;
expect(toText(info.displayParts)).toEqual('(method) myFunc(): void');
expect(toText(info.documentation)).toEqual('Documentation for myFunc.');
});
it('should work for accessed properties in writes', () => {
expectQuickInfo({
templateOverride: `<div (click)="hero.i¦d = 2"></div>`,
expectedSpanText: 'id',
expectedDisplayString: '(property) Hero.id: number'
});
});
it('should work for method call arguments', () => {
expectQuickInfo({
templateOverride: `<div (click)="setTitle(hero.nam¦e)"></div>`,
expectedSpanText: 'name',
expectedDisplayString: '(property) Hero.name: string'
});
});
it('should find members of two-way binding', () => {
expectQuickInfo({
templateOverride: `<input string-model [(model)]="ti¦tle" />`,
expectedSpanText: 'title',
expectedDisplayString: '(property) AppCmp.title: string'
});
});
it('should find members in a structural directive', () => {
expectQuickInfo({
templateOverride: `<div *ngIf="anyV¦alue"></div>`,
expectedSpanText: 'anyValue',
expectedDisplayString: '(property) AppCmp.anyValue: any'
});
});
it('should work for members in structural directives', () => {
expectQuickInfo({
templateOverride: `<div *ngFor="let item of her¦oes; trackBy: trackByFn;"></div>`,
expectedSpanText: 'heroes',
expectedDisplayString: '(property) AppCmp.heroes: Hero[]'
});
});
it('should work for the $any() cast function', () => {
expectQuickInfo({
templateOverride: `<div>{{$an¦y(title)}}</div>`,
expectedSpanText: '$any',
expectedDisplayString: '(method) $any: any'
});
});
it('should provide documentation', () => {
const template = project.openFile('app.html');
template.contents = `<div>{{title}}</div>`;
template.moveCursorToText('{{¦title}}');
const quickInfo = template.getQuickInfoAtPosition();
const documentation = toText(quickInfo!.documentation);
expect(documentation).toBe('This is the title of the `AppCmp` Component.');
});
});
it('should work for object literal with shorthand property declarations', () => {
initMockFileSystem('Native');
env = LanguageServiceTestEnv.setup();
project = env.addProject(
'test', {
'app.ts': `
import {Component, NgModule} from '@angular/core';
import {CommonModule} from '@angular/common';
@Component({
selector: 'some-cmp',
templateUrl: './app.html',
})
export class SomeCmp {
val1 = 'one';
val2 = 2;
doSomething(obj: {val1: string, val2: number}) {}
}
@NgModule({
declarations: [SomeCmp],
imports: [CommonModule],
})
export class AppModule{
}
`,
'app.html': `{{doSomething({val1, val2})}}`,
},
{strictTemplates: true});
env.expectNoSourceDiagnostics();
project.expectNoSourceDiagnostics();
const template = project.openFile('app.html');
template.moveCursorToText('val¦1');
const quickInfo = template.getQuickInfoAtPosition();
expect(toText(quickInfo!.displayParts)).toEqual('(property) SomeCmp.val1: string');
template.moveCursorToText('val¦2');
const quickInfo2 = template.getQuickInfoAtPosition();
expect(toText(quickInfo2!.displayParts)).toEqual('(property) SomeCmp.val2: number');
});
});
describe('generics', () => {
beforeEach(() => {
initMockFileSystem('Native');
env = LanguageServiceTestEnv.setup();
});
it('should get quick info for the generic input of a directive that normally requires inlining',
() => {
// When compiling normally, we would have to inline the type constructor of `GenericDir`
// because its generic type parameter references `PrivateInterface`, which is not exported.
project = env.addProject('test', {
'app.ts': `
import {Directive, Component, Input, NgModule} from '@angular/core';
interface PrivateInterface {}
@Directive({
selector: '[dir]'
})export class GenericDir <T extends PrivateInterface>{
@Input('input') input: T = null!;
}
@Component({
selector: 'some-cmp',
templateUrl: './app.html'
})export class SomeCmp{}
@NgModule({
declarations: [GenericDir, SomeCmp],
})export class AppModule{}
`,
'app.html': ``,
});
expectQuickInfo({
templateOverride: `<div dir [inp¦ut]='{value: 42}'></div>`,
expectedSpanText: 'input',
expectedDisplayString: '(property) GenericDir<any>.input: any'
});
});
});
describe('non-strict compiler options', () => {
beforeEach(() => {
initMockFileSystem('Native');
env = LanguageServiceTestEnv.setup();
});
it('should find input binding on text attribute when strictAttributeTypes is false', () => {
project = env.addProject('test', quickInfoSkeleton(), {strictAttributeTypes: false});
expectQuickInfo({
templateOverride: `<test-comp tcN¦ame="title"></test-comp>`,
expectedSpanText: 'tcName',
expectedDisplayString: '(property) TestComponent.name: string'
});
});
it('can still get quick info when strictOutputEventTypes is false', () => {
project = env.addProject('test', quickInfoSkeleton(), {strictOutputEventTypes: false});
expectQuickInfo({
templateOverride: `<test-comp (te¦st)="myClick($event)"></test-comp>`,
expectedSpanText: 'test',
expectedDisplayString: '(event) TestComponent.testEvent: EventEmitter<string>'
});
});
it('should work for pipes even if checkTypeOfPipes is false', () => {
// checkTypeOfPipes is set to false when strict templates is false
project = env.addProject('test', quickInfoSkeleton(), {strictTemplates: false});
const templateOverride = `<p>The hero's birthday is {{birthday | da¦te: "MM/dd/yy"}}</p>`;
expectQuickInfo({
templateOverride,
expectedSpanText: 'date',
expectedDisplayString:
'(pipe) DatePipe.transform(value: string | number | Date, format?: string | undefined, timezone?: ' +
'string | undefined, locale?: string | undefined): string | null (+2 overloads)'
});
});
it('should still get quick info if there is an invalid css resource', () => {
project = env.addProject('test', {
'app.ts': `
import {Component, NgModule} from '@angular/core';
@Component({
selector: 'some-cmp',
templateUrl: './app.html',
styleUrls: ['./does_not_exist'],
})
export class SomeCmp {
myValue!: string;
}
@NgModule({
declarations: [SomeCmp],
})
export class AppModule{
}
`,
'app.html': `{{myValue}}`,
});
const diagnostics = project.getDiagnosticsForFile('app.ts');
expect(diagnostics.length).toBe(1);
expect(diagnostics[0].messageText)
.toEqual(`Could not find stylesheet file './does_not_exist'.`);
const template = project.openFile('app.html');
template.moveCursorToText('{{myVa¦lue}}');
const quickInfo = template.getQuickInfoAtPosition();
expect(toText(quickInfo!.displayParts)).toEqual('(property) SomeCmp.myValue: string');
});
});
function expectQuickInfo(
{templateOverride, expectedSpanText, expectedDisplayString}:
{templateOverride: string, expectedSpanText: string, expectedDisplayString: string}):
ts.QuickInfo {
const text = templateOverride.replace('¦', '');
const template = project.openFile('app.html');
template.contents = text;
env.expectNoSourceDiagnostics();
template.moveCursorToText(templateOverride);
const quickInfo = template.getQuickInfoAtPosition();
expect(quickInfo).toBeTruthy();
const {textSpan, displayParts} = quickInfo!;
expect(text.substring(textSpan.start, textSpan.start + textSpan.length))
.toEqual(expectedSpanText);
expect(toText(displayParts)).toEqual(expectedDisplayString);
return quickInfo!;
}
});
function toText(displayParts?: ts.SymbolDisplayPart[]): string {
return (displayParts || []).map(p => p.text).join('');
}