/** * @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, model} 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'}]; 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; } @Directive({ selector: '[signal-model]', exportAs: 'signalModel', }) export class SignalModel { signalModel = model(); } @Directive({selector: 'button[custom-button][compound]'}) export class CompoundCustomButtonDirective { @Input() config?: {color?: string}; } @NgModule({ declarations: [ AppCmp, CompoundCustomButtonDirective, StringModel, TestComponent, SignalModel, ], 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' }); }); it('should work for signal-based two-way binding providers', () => { expectQuickInfo({ templateOverride: ``, expectedSpanText: 'signalModel', expectedDisplayString: '(property) SignalModel.signalModel: ModelSignal' }); }); }); 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.\n\n' + '[MDN Reference](https://developer.mozilla.org/docs/Web/API/HTMLDivElement)'); }); 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"; }' }); }); it('should work for safe keyed reads', () => { expectQuickInfo({ templateOverride: `
{{constNamesOptional?.[0¦]}}
`, expectedSpanText: '0', expectedDisplayString: '(property) 0: {\n readonly name: "name";\n}' }); expectQuickInfo({ templateOverride: `
{{constNamesOptional?.[0]?.na¦me}}
`, expectedSpanText: 'constNamesOptional?.[0]?.name', expectedDisplayString: '(property) 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 safe method calls', () => { const files = { 'app.ts': `import {Component} from '@angular/core'; @Component({template: '
'}) 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: `
`, 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('blocks', () => { describe('defer & friends', () => { it('defer', () => { expectQuickInfo({ templateOverride: `@de¦fer { } @placeholder { }`, expectedSpanText: '@defer ', expectedDisplayString: '(block) @defer' }); }); it('defer with condition', () => { expectQuickInfo({ templateOverride: `@de¦fer (on immediate) { } @placeholder { }`, expectedSpanText: '@defer ', expectedDisplayString: '(block) @defer' }); }); it('placeholder', () => { expectQuickInfo({ templateOverride: `@defer { } @pla¦ceholder { }`, expectedSpanText: '@placeholder ', expectedDisplayString: '(block) @placeholder' }); }); it('loading', () => { expectQuickInfo({ templateOverride: `@defer { } @loadin¦g { }`, expectedSpanText: '@loading ', expectedDisplayString: '(block) @loading' }); }); it('error', () => { expectQuickInfo({ templateOverride: `@defer { } @erro¦r { }`, expectedSpanText: '@error ', expectedDisplayString: '(block) @error' }); }); describe('triggers', () => { it('viewport', () => { expectQuickInfo({ templateOverride: `@defer (on vie¦wport(x)) { }
`, expectedSpanText: 'viewport', expectedDisplayString: '(trigger) viewport' }); }); it('immediate', () => { expectQuickInfo({ templateOverride: `@defer (on imme¦diate) {}`, expectedSpanText: 'immediate', expectedDisplayString: '(trigger) immediate' }); }); it('idle', () => { expectQuickInfo({ templateOverride: `@defer (on i¦dle) { } `, expectedSpanText: 'idle', expectedDisplayString: '(trigger) idle' }); }); it('hover', () => { expectQuickInfo({ templateOverride: `@defer (on hov¦er(x)) { }
`, expectedSpanText: 'hover', expectedDisplayString: '(trigger) hover' }); }); it('timer', () => { expectQuickInfo({ templateOverride: `@defer (on tim¦er(100)) { } `, expectedSpanText: 'timer', expectedDisplayString: '(trigger) timer' }); }); it('interaction', () => { expectQuickInfo({ templateOverride: `@defer (on interactio¦n(x)) { }
`, expectedSpanText: 'interaction', expectedDisplayString: '(trigger) interaction' }); }); it('when', () => { expectQuickInfo({ templateOverride: `@defer (whe¦n title) { }
`, expectedSpanText: 'when', expectedDisplayString: '(keyword) when' }); }); it('prefetch (when)', () => { expectQuickInfo({ templateOverride: `@defer (prefet¦ch when title) { }`, expectedSpanText: 'prefetch', expectedDisplayString: '(keyword) prefetch' }); }); it('on', () => { expectQuickInfo({ templateOverride: `@defer (o¦n immediate) { } `, expectedSpanText: 'on', expectedDisplayString: '(keyword) on' }); }); it('prefetch (on)', () => { expectQuickInfo({ templateOverride: `@defer (prefet¦ch on immediate) { }`, expectedSpanText: 'prefetch', expectedDisplayString: '(keyword) prefetch' }); }); }); }); it('empty', () => { expectQuickInfo({ templateOverride: `@for (name of constNames; track $index) {} @em¦pty {}`, expectedSpanText: '@empty ', expectedDisplayString: '(block) @empty' }); }); it('track keyword', () => { expectQuickInfo({ templateOverride: `@for (name of constNames; tr¦ack $index) {}`, expectedSpanText: 'track', expectedDisplayString: '(keyword) track' }); }); it('implicit variable assignment', () => { expectQuickInfo({ templateOverride: `@for (name of constNames; track $index; let od¦d = $odd) {}`, expectedSpanText: 'odd', expectedDisplayString: '(variable) odd: boolean' }); }); it('implicit variable assignment in comma separated list', () => { expectQuickInfo({ templateOverride: `@for (name of constNames; track index; let odd = $odd, ind¦ex = $index) {}`, expectedSpanText: 'index', expectedDisplayString: '(variable) index: number' }); }); it('if block alias variable', () => { expectQuickInfo({ templateOverride: `@if (constNames; as al¦iasName) {}`, expectedSpanText: 'aliasName', expectedDisplayString: '(variable) aliasName: [{ readonly name: "name"; }]' }); }); }); 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 { @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(''); }