/** * @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 * as ts from 'typescript/lib/tsserverlibrary'; import {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: '
Testing: {{name}}
', }) export class TestComponent { @Input('tcName') name!: string; @Output('test') testEvent!: EventEmitter; } /*EndTestComponent*/ @Component({ selector: 'app-cmp', templateUrl: './app.html', }) export class AppCmp { hero!: Hero; heroes!: Hero[]; readonlyHeroes!: ReadonlyArray>; /** * This is the title of the \`AppCmp\` Component. */ title!: string; constNames!: [{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; } @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: ``, expectedSpanText: '', expectedDisplayString: '(element) button: HTMLButtonElement' }); }); it('should work for directives which match native element tags', () => { expectQuickInfo({ templateOverride: ``, expectedSpanText: '', expectedDisplayString: '(directive) AppModule.CompoundCustomButtonDirective' }); }); }); describe('templates', () => { it('should return undefined for ng-templates', () => { const {documentation} = expectQuickInfo({ templateOverride: ``, expectedSpanText: '', expectedDisplayString: '(template) ng-template' }); expect(toText(documentation)) .toContain('The `` is an Angular element for rendering HTML.'); }); }); describe('directives', () => { it('should work for directives', () => { expectQuickInfo({ templateOverride: `
`, expectedSpanText: 'string-model', expectedDisplayString: '(directive) AppModule.StringModel' }); }); it('should work for components', () => { const {documentation} = expectQuickInfo({ templateOverride: ``, expectedSpanText: '', 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: ``, expectedSpanText: ``, 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: `
`, expectedSpanText: 'ngFor', expectedDisplayString: '(directive) NgForOf' }); 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: `{{hero}}`, expectedSpanText: 'ngFor', expectedDisplayString: '(directive) NgForOf' }); }); it('should work for data-let- syntax', () => { expectQuickInfo({ templateOverride: `{{hero}}`, expectedSpanText: 'hero', expectedDisplayString: '(variable) hero: Hero' }); }); }); describe('bindings', () => { describe('inputs', () => { it('should work for input providers', () => { expectQuickInfo({ templateOverride: ``, expectedSpanText: 'tcName', expectedDisplayString: '(property) TestComponent.name: string' }); }); it('should work for bind- syntax', () => { expectQuickInfo({ templateOverride: ``, expectedSpanText: 'tcName', expectedDisplayString: '(property) TestComponent.name: string' }); expectQuickInfo({ templateOverride: ``, expectedSpanText: 'tcName', expectedDisplayString: '(property) TestComponent.name: string' }); }); it('should work for structural directive inputs ngForTrackBy', () => { expectQuickInfo({ templateOverride: `
`, expectedSpanText: 'trackBy', expectedDisplayString: '(property) NgForOf.ngForTrackBy: TrackByFunction' }); }); it('should work for structural directive inputs ngForOf', () => { expectQuickInfo({ templateOverride: `
`, expectedSpanText: 'of', expectedDisplayString: '(property) NgForOf.ngForOf: (Hero[] & NgIterable) | null | undefined' }); }); it('should work for two-way binding providers', () => { expectQuickInfo({ templateOverride: ``, expectedSpanText: 'model', expectedDisplayString: '(property) StringModel.model: string' }); }); }); describe('outputs', () => { it('should work for event providers', () => { expectQuickInfo({ templateOverride: ``, expectedSpanText: 'test', expectedDisplayString: '(event) TestComponent.testEvent: EventEmitter' }); }); it('should work for on- syntax binding', () => { expectQuickInfo({ templateOverride: ``, expectedSpanText: 'test', expectedDisplayString: '(event) TestComponent.testEvent: EventEmitter' }); expectQuickInfo({ templateOverride: ``, expectedSpanText: 'test', expectedDisplayString: '(event) TestComponent.testEvent: EventEmitter' }); }); it('should work for $event from EventEmitter', () => { expectQuickInfo({ templateOverride: `
`, expectedSpanText: '$event', expectedDisplayString: '(parameter) $event: string' }); }); it('should work for $event from native element', () => { expectQuickInfo({ templateOverride: `
`, expectedSpanText: '$event', expectedDisplayString: '(parameter) $event: MouseEvent' }); }); }); }); describe('references', () => { it('should work for element reference declarations', () => { const {documentation} = expectQuickInfo({ templateOverride: `
`, 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
elements.'); }); it('should work for directive references', () => { expectQuickInfo({ templateOverride: `
`, expectedSpanText: 'dirRef', expectedDisplayString: '(reference) dirRef: StringModel' }); }); it('should work for ref- syntax', () => { expectQuickInfo({ templateOverride: `
`, expectedSpanText: 'chart', expectedDisplayString: '(reference) chart: HTMLDivElement' }); expectQuickInfo({ templateOverride: `
`, expectedSpanText: 'chart', expectedDisplayString: '(reference) chart: HTMLDivElement' }); }); it('should work for $event from native element', () => { expectQuickInfo({ templateOverride: `
`, expectedSpanText: '$event', expectedDisplayString: '(parameter) $event: MouseEvent' }); }); it('should work for click output from native element', () => { expectQuickInfo({ templateOverride: `
`, 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: `
{{her¦o}}
`, expectedSpanText: 'hero', expectedDisplayString: '(variable) hero: Hero' }); expect(toText(documentation)).toEqual('The most heroic being.'); }); it('should work for ReadonlyArray members (#36191)', () => { expectQuickInfo({ templateOverride: `
{{her¦o}}
`, expectedSpanText: 'hero', expectedDisplayString: '(variable) hero: Readonly' }); }); it('should work for const array members (#36191)', () => { expectQuickInfo({ templateOverride: `
{{na¦me}}
`, expectedSpanText: 'name', expectedDisplayString: '(variable) name: { readonly name: "name"; }' }); }); }); describe('pipes', () => { it('should work for pipes', () => { const templateOverride = `

The hero's birthday is {{birthday | da¦te: "MM/dd/yy"}}

`; 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: `
{{ tit¦le }}
`, expectedSpanText: 'title', expectedDisplayString: '(property) AppCmp.title: string' }); }); it('should work for accessed property reads', () => { expectQuickInfo({ templateOverride: `
{{title.len¦gth}}
`, expectedSpanText: 'length', expectedDisplayString: '(property) String.length: number' }); }); it('should find members in an attribute interpolation', () => { expectQuickInfo({ templateOverride: `
`, expectedSpanText: 'title', expectedDisplayString: '(property) AppCmp.title: string' }); }); it('should find members of input binding', () => { expectQuickInfo({ templateOverride: ``, expectedSpanText: 'title', expectedDisplayString: '(property) AppCmp.title: string' }); }); it('should find input binding on text attribute', () => { expectQuickInfo({ templateOverride: ``, expectedSpanText: 'tcName', expectedDisplayString: '(property) TestComponent.name: string' }); }); it('should find members of event binding', () => { expectQuickInfo({ templateOverride: ``, expectedSpanText: 'title', expectedDisplayString: '(property) AppCmp.title: string' }); }); it('should work for method calls', () => { expectQuickInfo({ templateOverride: `
`, expectedSpanText: 'setTitle', expectedDisplayString: '(method) AppCmp.setTitle(newTitle: string): void' }); }); it('should work for accessed properties in writes', () => { expectQuickInfo({ templateOverride: `
`, expectedSpanText: 'id', expectedDisplayString: '(property) Hero.id: number' }); }); it('should work for method call arguments', () => { expectQuickInfo({ templateOverride: `
`, expectedSpanText: 'name', expectedDisplayString: '(property) Hero.name: string' }); }); it('should find members of two-way binding', () => { expectQuickInfo({ templateOverride: ``, expectedSpanText: 'title', expectedDisplayString: '(property) AppCmp.title: string' }); }); it('should find members in a structural directive', () => { expectQuickInfo({ templateOverride: `
`, expectedSpanText: 'anyValue', expectedDisplayString: '(property) AppCmp.anyValue: any' }); }); it('should work for members in structural directives', () => { expectQuickInfo({ templateOverride: `
`, expectedSpanText: 'heroes', expectedDisplayString: '(property) AppCmp.heroes: Hero[]' }); }); it('should work for the $any() cast function', () => { expectQuickInfo({ templateOverride: `
{{$an¦y(title)}}
`, expectedSpanText: '$any', expectedDisplayString: '(method) $any: any' }); }); it('should provide documentation', () => { const template = project.openFile('app.html'); template.contents = `
{{title}}
`; template.moveCursorToText('{{¦title}}'); const quickInfo = template.getQuickInfoAtPosition(); const documentation = toText(quickInfo!.documentation); expect(documentation).toBe('This is the title of the `AppCmp` Component.'); }); }); }); 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 { @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: `
`, expectedSpanText: 'input', expectedDisplayString: '(property) GenericDir.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: ``, 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: ``, expectedSpanText: 'test', expectedDisplayString: '(event) TestComponent.testEvent: EventEmitter' }); }); 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 = `

The hero's birthday is {{birthday | da¦te: "MM/dd/yy"}}

`; 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(''); }