From 67c914819aaf9400f73cc32f157518a75c32d5a6 Mon Sep 17 00:00:00 2001 From: Keen Yee Liau Date: Mon, 14 Oct 2019 15:24:28 -0700 Subject: [PATCH] test(language-service): Move completions test to completions_spec.ts (#33159) There are many specs in `ts_plugin_spec.ts` that exercise the behavior of completions. These specs should belong in `completions_spec` instead. In addition, 1. Tests for `getExternalFiles()` added in `ts_plugin_spec.ts` 2. Fixed bug in MockHost.reset() to remove overriden script names 3. Add test for TS diagnostics when `angularOnly = true` is not set PR Close #33159 --- .../language-service/test/completions_spec.ts | 437 ++++++++++++------ .../test/project/app/parsing-cases.ts | 8 + packages/language-service/test/test_utils.ts | 9 + .../language-service/test/ts_plugin_spec.ts | 358 ++++++-------- 4 files changed, 464 insertions(+), 348 deletions(-) diff --git a/packages/language-service/test/completions_spec.ts b/packages/language-service/test/completions_spec.ts index 3a5e237020..038a72d910 100644 --- a/packages/language-service/test/completions_spec.ts +++ b/packages/language-service/test/completions_spec.ts @@ -9,172 +9,335 @@ import * as ts from 'typescript'; import {createLanguageService} from '../src/language_service'; +import {CompletionKind} from '../src/types'; import {TypeScriptServiceHost} from '../src/typescript_host'; import {MockTypescriptHost} from './test_utils'; +const APP_COMPONENT = '/app/app.component.ts'; +const PARSING_CASES = '/app/parsing-cases.ts'; +const TEST_TEMPLATE = '/app/test.ng'; +const EXPRESSION_CASES = '/app/expression-cases.ts'; + describe('completions', () => { - let mockHost = new MockTypescriptHost(['/app/main.ts', '/app/parsing-cases.ts']); - let service = ts.createLanguageService(mockHost); - let ngHost = new TypeScriptServiceHost(mockHost, service); - let ngService = createLanguageService(ngHost); + const mockHost = new MockTypescriptHost(['/app/main.ts']); + const tsLS = ts.createLanguageService(mockHost); + const ngHost = new TypeScriptServiceHost(mockHost, tsLS); + const ngLS = createLanguageService(ngHost); - beforeEach(() => { mockHost.reset(); }); - - it('should be able to get entity completions', - () => { expectContains('/app/test.ng', 'entity-amp', '&', '>', '<', 'ι'); }); + it('should be able to get entity completions', () => { + const marker = mockHost.getLocationMarkerFor(APP_COMPONENT, 'entity-amp'); + const completions = ngLS.getCompletionsAt(APP_COMPONENT, marker.start); + expectContain(completions, CompletionKind.ENTITY, ['&', '>', '<', 'ι']); + }); it('should be able to return html elements', () => { - let htmlTags = ['h1', 'h2', 'div', 'span']; - let locations = ['empty', 'start-tag-h1', 'h1-content', 'start-tag', 'start-tag-after-h']; - for (let location of locations) { - expectContains('/app/test.ng', location, ...htmlTags); + const locations = ['empty', 'start-tag-h1', 'h1-content', 'start-tag', 'start-tag-after-h']; + for (const location of locations) { + const marker = mockHost.getLocationMarkerFor(APP_COMPONENT, location); + const completions = ngLS.getCompletionsAt(APP_COMPONENT, marker.start); + expectContain(completions, CompletionKind.ELEMENT, ['div', 'h1', 'h2', 'span']); } }); - it('should be able to return element diretives', - () => { expectContains('/app/test.ng', 'empty', 'my-app'); }); - - it('should be able to return h1 attributes', - () => { expectContains('/app/test.ng', 'h1-after-space', 'id', 'dir', 'lang', 'onclick'); }); - - it('should be able to find common angular attributes', - () => { expectContains('/app/test.ng', 'div-attributes', '(click)', '[ngClass]'); }); - - it('should be able to infer the type of a ngForOf', () => { - const fileName = mockHost.addCode(` - interface Person { - name: string, - street: string - } - - @Component({template: '
{{person.~{name}name}} { + const marker = mockHost.getLocationMarkerFor(APP_COMPONENT, 'empty'); + const completions = ngLS.getCompletionsAt(APP_COMPONENT, marker.start); + expectContain(completions, CompletionKind.COMPONENT, [ + 'ng-form', + 'my-app', + 'ng-component', + 'test-comp', + ]); }); - it('should be able to get completions for exported *ngIf variable', () => { - const fileName = mockHost.addCode(` - interface Person { - name: string, - street: string - } - - @Component({template: '
{{person.~{name}name}} - }`); - expectContains(fileName, 'name', 'name', 'street'); + it('should be able to return h1 attributes', () => { + const marker = mockHost.getLocationMarkerFor(APP_COMPONENT, 'h1-after-space'); + const completions = ngLS.getCompletionsAt(APP_COMPONENT, marker.start); + expectContain(completions, CompletionKind.HTML_ATTRIBUTE, [ + 'class', + 'id', + 'onclick', + 'onmouseup', + ]); }); - it('should be able to infer the type of a ngForOf with an async pipe', () => { - const fileName = mockHost.addCode(` - interface Person { - name: string, - street: string - } - - @Component({template: '
{{person.~{name}name}}
'}) - export class MyComponent { - people: Promise; - }`); - expectContains(fileName, 'name', 'name', 'street'); + it('should be able to find common angular attributes', () => { + const marker = mockHost.getLocationMarkerFor(APP_COMPONENT, 'div-attributes'); + const completions = ngLS.getCompletionsAt(APP_COMPONENT, marker.start); + expectContain(completions, CompletionKind.ATTRIBUTE, [ + '(click)', + '[ngClass]', + '*ngIf', + '*ngFor', + ]); }); - describe('with regression tests', () => { - it('should not crash with an incomplete component', () => { - expect(() => { - const fileName = mockHost.addCode(` - @Component({ - template: '~{inside-template}' - }) - export class MyComponent { + it('should be able to get the completions at the beginning of an interpolation', () => { + const marker = mockHost.getLocationMarkerFor(APP_COMPONENT, 'h2-hero'); + const completions = ngLS.getCompletionsAt(APP_COMPONENT, marker.start); + expectContain(completions, CompletionKind.PROPERTY, ['title', 'hero']); + }); - }`); + it('should not include private members of a class', () => { + const marker = mockHost.getLocationMarkerFor(APP_COMPONENT, 'h2-hero'); + const completions = ngLS.getCompletionsAt(APP_COMPONENT, marker.start); + expect(completions).toBeDefined(); + const internal = completions !.entries.find(e => e.name === 'internal'); + expect(internal).toBeUndefined(); + }); - expectContains(fileName, 'inside-template', 'h1'); - }).not.toThrow(); + it('should be able to get the completions at the end of an interpolation', () => { + const marker = mockHost.getLocationMarkerFor(APP_COMPONENT, 'sub-end'); + const completions = ngLS.getCompletionsAt(APP_COMPONENT, marker.start); + expectContain(completions, CompletionKind.PROPERTY, ['title', 'hero']); + }); + + it('should be able to get the completions in a property', () => { + const marker = mockHost.getLocationMarkerFor(APP_COMPONENT, 'h2-name'); + const completions = ngLS.getCompletionsAt(APP_COMPONENT, marker.start); + expectContain(completions, CompletionKind.PROPERTY, ['id', 'name']); + }); + + it('should be able to return attribute names with an incompete attribute', () => { + const marker = mockHost.getLocationMarkerFor(PARSING_CASES, 'no-value-attribute'); + const completions = ngLS.getCompletionsAt(PARSING_CASES, marker.start); + expectContain(completions, CompletionKind.HTML_ATTRIBUTE, ['id', 'class', 'dir', 'lang']); + }); + + it('should be able to return attributes of an incomplete element', () => { + const m1 = mockHost.getLocationMarkerFor(PARSING_CASES, 'incomplete-open-lt'); + const c1 = ngLS.getCompletionsAt(PARSING_CASES, m1.start); + expectContain(c1, CompletionKind.ELEMENT, ['a', 'div', 'p', 'span']); + + const m2 = mockHost.getLocationMarkerFor(PARSING_CASES, 'incomplete-open-a'); + const c2 = ngLS.getCompletionsAt(PARSING_CASES, m2.start); + expectContain(c2, CompletionKind.ELEMENT, ['a', 'div', 'p', 'span']); + + const m3 = mockHost.getLocationMarkerFor(PARSING_CASES, 'incomplete-open-attr'); + const c3 = ngLS.getCompletionsAt(PARSING_CASES, m3.start); + expectContain(c3, CompletionKind.HTML_ATTRIBUTE, ['id', 'class', 'href', 'name']); + }); + + it('should be able to return completions with a missing closing tag', () => { + const marker = mockHost.getLocationMarkerFor(PARSING_CASES, 'missing-closing'); + const completions = ngLS.getCompletionsAt(PARSING_CASES, marker.start); + expectContain(completions, CompletionKind.ELEMENT, ['a', 'div', 'p', 'span', 'h1', 'h2']); + }); + + it('should be able to return common attributes of an unknown tag', () => { + const marker = mockHost.getLocationMarkerFor(PARSING_CASES, 'unknown-element'); + const completions = ngLS.getCompletionsAt(PARSING_CASES, marker.start); + expectContain(completions, CompletionKind.HTML_ATTRIBUTE, ['id', 'dir', 'lang']); + }); + + it('should be able to get completions in an empty interpolation', () => { + const marker = mockHost.getLocationMarkerFor(PARSING_CASES, 'empty-interpolation'); + const completions = ngLS.getCompletionsAt(PARSING_CASES, marker.start); + expectContain(completions, CompletionKind.PROPERTY, ['title', 'subTitle']); + }); + + describe('in external template', () => { + it('should be able to get entity completions in external template', () => { + const marker = mockHost.getLocationMarkerFor(TEST_TEMPLATE, 'entity-amp'); + const completions = ngLS.getCompletionsAt(TEST_TEMPLATE, marker.start); + expectContain(completions, CompletionKind.ENTITY, ['&', '>', '<', 'ι']); }); - it('should hot crash with an incomplete class', () => { - expect(() => { - mockHost.addCode('\nexport class'); - ngHost.getAnalyzedModules(); - }).not.toThrow(); + it('should be able to return html elements', () => { + const locations = ['empty', 'start-tag-h1', 'h1-content', 'start-tag', 'start-tag-after-h']; + for (const location of locations) { + const marker = mockHost.getLocationMarkerFor(TEST_TEMPLATE, location); + const completions = ngLS.getCompletionsAt(TEST_TEMPLATE, marker.start); + expectContain(completions, CompletionKind.ELEMENT, ['div', 'h1', 'h2', 'span']); + } }); - }); - - it('should respect paths configuration', () => { - mockHost.overrideOptions({ - baseUrl: '/app', - paths: {'bar/*': ['foo/bar/*']}, + it('should be able to return element diretives', () => { + const marker = mockHost.getLocationMarkerFor(TEST_TEMPLATE, 'empty'); + const completions = ngLS.getCompletionsAt(TEST_TEMPLATE, marker.start); + expectContain(completions, CompletionKind.COMPONENT, [ + 'ng-form', + 'my-app', + 'ng-component', + 'test-comp', + ]); }); - mockHost.addScript('/app/foo/bar/shared.ts', ` - export interface Node { - children: Node[]; - } - `); - mockHost.addScript('/app/my.component.ts', ` - import { Component } from '@angular/core'; - import { Node } from 'bar/shared'; - @Component({ - selector: 'my-component', - template: '{{tree.~{tree} }}' - }) - export class MyComponent { - tree: Node; - } - `); - ngHost.getAnalyzedModules(); - expectContains('/app/my.component.ts', 'tree', 'children'); + it('should be able to return h1 attributes', () => { + const marker = mockHost.getLocationMarkerFor(TEST_TEMPLATE, 'h1-after-space'); + const completions = ngLS.getCompletionsAt(TEST_TEMPLATE, marker.start); + expectContain(completions, CompletionKind.HTML_ATTRIBUTE, [ + 'class', + 'id', + 'onclick', + 'onmouseup', + ]); + }); + + it('should be able to find common angular attributes', () => { + const marker = mockHost.getLocationMarkerFor(TEST_TEMPLATE, 'div-attributes'); + const completions = ngLS.getCompletionsAt(TEST_TEMPLATE, marker.start); + expectContain(completions, CompletionKind.ATTRIBUTE, [ + '(click)', + '[ngClass]', + '*ngIf', + '*ngFor', + ]); + }); }); - it('should work with input and output', () => { - const fileName = mockHost.addCode(` - @Component({ - selector: 'foo-component', - template: \` -
-
- \`, - }) - export class FooComponent { - text: string; - value: number; - } - `); - expectContains(fileName, 'stringMarker', '[model]', '(model)'); - expectContains(fileName, 'numberMarker', '[inputAlias]', '(outputAlias)'); + describe('with a *ngIf', () => { + it('should be able to get completions for exported *ngIf variable', () => { + const marker = mockHost.getLocationMarkerFor(PARSING_CASES, 'promised-person-name'); + const completions = ngLS.getCompletionsAt(PARSING_CASES, marker.start); + expectContain(completions, CompletionKind.PROPERTY, ['name', 'age', 'street']); + }); }); - function expectContains(fileName: string, locationMarker: string, ...names: string[]) { - const marker = mockHost.getLocationMarkerFor(fileName, locationMarker); - expectEntries(locationMarker, ngService.getCompletionsAt(fileName, marker.start), ...names); - } + describe('with a *ngFor', () => { + it('should include a let for empty attribute', () => { + const marker = mockHost.getLocationMarkerFor(PARSING_CASES, 'for-empty'); + const completions = ngLS.getCompletionsAt(PARSING_CASES, marker.start); + expectContain(completions, CompletionKind.KEY, ['let', 'of']); + }); + + it('should suggest NgForRow members for let initialization expression', () => { + const marker = mockHost.getLocationMarkerFor(PARSING_CASES, 'for-let-i-equal'); + const completions = ngLS.getCompletionsAt(PARSING_CASES, marker.start); + expectContain(completions, CompletionKind.PROPERTY, [ + '$implicit', + 'ngForOf', + 'index', + 'count', + 'first', + 'last', + 'even', + 'odd', + ]); + }); + + it('should include a let', () => { + const marker = mockHost.getLocationMarkerFor(PARSING_CASES, 'for-let'); + const completions = ngLS.getCompletionsAt(PARSING_CASES, marker.start); + expectContain(completions, CompletionKind.KEY, ['let', 'of']); + }); + + it('should include an "of"', () => { + const marker = mockHost.getLocationMarkerFor(PARSING_CASES, 'for-of'); + const completions = ngLS.getCompletionsAt(PARSING_CASES, marker.start); + expectContain(completions, CompletionKind.KEY, ['let', 'of']); + }); + + it('should include field reference', () => { + const marker = mockHost.getLocationMarkerFor(PARSING_CASES, 'for-people'); + const completions = ngLS.getCompletionsAt(PARSING_CASES, marker.start); + expectContain(completions, CompletionKind.PROPERTY, ['people']); + }); + + it('should include person in the let scope', () => { + const marker = mockHost.getLocationMarkerFor(PARSING_CASES, 'for-interp-person'); + const completions = ngLS.getCompletionsAt(PARSING_CASES, marker.start); + expectContain(completions, CompletionKind.VARIABLE, ['person']); + }); + + it('should be able to infer the type of a ngForOf', () => { + for (const location of ['for-interp-name', 'for-interp-age']) { + const marker = mockHost.getLocationMarkerFor(PARSING_CASES, location); + const completions = ngLS.getCompletionsAt(PARSING_CASES, marker.start); + expectContain(completions, CompletionKind.PROPERTY, ['name', 'age', 'street']); + } + }); + + it('should be able to infer the type of a ngForOf with an async pipe', () => { + const marker = mockHost.getLocationMarkerFor(PARSING_CASES, 'async-person-name'); + const completions = ngLS.getCompletionsAt(PARSING_CASES, marker.start); + expectContain(completions, CompletionKind.PROPERTY, ['name', 'age', 'street']); + }); + }); + + describe('data binding', () => { + it('should be able to complete property value', () => { + const marker = mockHost.getLocationMarkerFor(PARSING_CASES, 'property-binding-model'); + const completions = ngLS.getCompletionsAt(PARSING_CASES, marker.start); + expectContain(completions, CompletionKind.PROPERTY, ['test']); + }); + + it('should be able to complete an event', () => { + const marker = mockHost.getLocationMarkerFor(PARSING_CASES, 'event-binding-model'); + const completions = ngLS.getCompletionsAt(PARSING_CASES, marker.start); + expectContain(completions, CompletionKind.METHOD, ['modelChanged']); + }); + + it('should be able to complete a two-way binding', () => { + const marker = mockHost.getLocationMarkerFor(PARSING_CASES, 'two-way-binding-model'); + const completions = ngLS.getCompletionsAt(PARSING_CASES, marker.start); + expectContain(completions, CompletionKind.PROPERTY, ['test']); + }); + + it('should work with input and output', () => { + const m1 = mockHost.getLocationMarkerFor(PARSING_CASES, 'string-marker'); + const c1 = ngLS.getCompletionsAt(PARSING_CASES, m1.start); + expectContain(c1, CompletionKind.ATTRIBUTE, ['[model]', '(model)']); + + const m2 = mockHost.getLocationMarkerFor(PARSING_CASES, 'number-marker'); + const c2 = ngLS.getCompletionsAt(PARSING_CASES, m2.start); + expectContain(c2, CompletionKind.ATTRIBUTE, ['[inputAlias]', '(outputAlias)']); + }); + }); + + describe('for pipes', () => { + it('should be able to get a list of pipe values', () => { + for (const location of ['before-pipe', 'in-pipe', 'after-pipe']) { + const marker = mockHost.getLocationMarkerFor(PARSING_CASES, location); + const completions = ngLS.getCompletionsAt(PARSING_CASES, marker.start); + expectContain(completions, CompletionKind.PIPE, [ + 'async', + 'uppercase', + 'lowercase', + 'titlecase', + ]); + } + }); + + it('should be able to resolve lowercase', () => { + const marker = mockHost.getLocationMarkerFor(EXPRESSION_CASES, 'string-pipe'); + const completions = ngLS.getCompletionsAt(EXPRESSION_CASES, marker.start); + expectContain(completions, CompletionKind.METHOD, [ + 'charAt', + 'replace', + 'substring', + 'toLowerCase', + ]); + }); + }); + + describe('with references', () => { + it('should list references', () => { + const marker = mockHost.getLocationMarkerFor(PARSING_CASES, 'test-comp-content'); + const completions = ngLS.getCompletionsAt(PARSING_CASES, marker.start); + expectContain(completions, CompletionKind.REFERENCE, ['div', 'test1', 'test2']); + }); + + it('should reference the component', () => { + const marker = mockHost.getLocationMarkerFor(PARSING_CASES, 'test-comp-after-test'); + const completions = ngLS.getCompletionsAt(PARSING_CASES, marker.start); + expectContain(completions, CompletionKind.PROPERTY, ['name', 'testEvent']); + }); + + // TODO: Enable when we have a flag that indicates the project targets the DOM + // it('should reference the element if no component', () => { + // const marker = mockHost.getLocationMarkerFor(PARSING_CASES, 'test-comp-after-div'); + // const completions = ngLS.getCompletionsAt(PARSING_CASES, marker.start); + // expectContain(completions, CompletionKind.PROPERTY, ['innerText']); + // }); + }); }); - -function expectEntries( - locationMarker: string, completion: ts.CompletionInfo | undefined, ...names: string[]) { - let entries: {[name: string]: boolean} = {}; - if (!completion) { - throw new Error( - `Expected result from ${locationMarker} to include ${names.join(', ')} but no result provided`); - } - if (!completion.entries.length) { - throw new Error( - `Expected result from ${locationMarker} to include ${names.join(', ')} an empty result provided`); - } - for (const entry of completion.entries) { - entries[entry.name] = true; - } - let missing = names.filter(name => !entries[name]); - if (missing.length) { - throw new Error( - `Expected result from ${locationMarker} to include at least one of the following, ${missing.join(', ')}, in the list of entries ${completion.entries.map(entry => entry.name).join(', ')}`); - } +function expectContain( + completions: ts.CompletionInfo | undefined, kind: CompletionKind, names: string[]) { + expect(completions).toBeDefined(); + expect(completions !.entries).toEqual(jasmine.arrayContaining(names.map(name => { + return jasmine.objectContaining({name, kind}); + }) as any)); } diff --git a/packages/language-service/test/project/app/parsing-cases.ts b/packages/language-service/test/project/app/parsing-cases.ts index d967243b9b..958aea6bcd 100644 --- a/packages/language-service/test/project/app/parsing-cases.ts +++ b/packages/language-service/test/project/app/parsing-cases.ts @@ -143,10 +143,18 @@ export class ForUsingComponent {
{{person.~{async-person-name}name}}
+
+ {{person.~{promised-person-name}name}} +
`, }) export class AsyncForUsingComponent { people: Promise = Promise.resolve([]); + promisedPerson: Promise = Promise.resolve({ + name: 'John Doe', + age: 42, + street: '123 Angular Ln', + }); } @Component({ diff --git a/packages/language-service/test/test_utils.ts b/packages/language-service/test/test_utils.ts index 82769bd195..30637b7bbc 100644 --- a/packages/language-service/test/test_utils.ts +++ b/packages/language-service/test/test_utils.ts @@ -185,6 +185,15 @@ export class MockTypescriptHost implements ts.LanguageServiceHost { * Reset the project to its original state, effectively removing all overrides. */ reset() { + // Remove overrides from scriptNames + let length = 0; + for (let i = 0; i < this.scriptNames.length; ++i) { + const fileName = this.scriptNames[i]; + if (!this.overrides.has(fileName)) { + this.scriptNames[length++] = fileName; + } + } + this.scriptNames.splice(length); this.overrides.clear(); this.overrideDirectory.clear(); this.options = COMPILER_OPTIONS; diff --git a/packages/language-service/test/ts_plugin_spec.ts b/packages/language-service/test/ts_plugin_spec.ts index fbe2063a8f..6cadb91830 100644 --- a/packages/language-service/test/ts_plugin_spec.ts +++ b/packages/language-service/test/ts_plugin_spec.ts @@ -6,230 +6,166 @@ * found in the LICENSE file at https://angular.io/license */ -import 'reflect-metadata'; import * as ts from 'typescript'; -import {create} from '../src/ts_plugin'; + +import {create, getExternalFiles} from '../src/ts_plugin'; +import {CompletionKind} from '../src/types'; + import {MockTypescriptHost} from './test_utils'; -describe('plugin', () => { - const mockHost = new MockTypescriptHost(['/app/main.ts', '/app/parsing-cases.ts']); - const service = ts.createLanguageService(mockHost); - const program = service.getProgram(); - const plugin = createPlugin(service, mockHost); +const mockProject = { + projectService: { + logger: { + info() {}, + hasLevel: () => false, + }, + }, + hasRoots: () => true, +} as any; - it('should not report errors on tour of heroes', () => { - expectNoDiagnostics(service.getCompilerOptionsDiagnostics()); - for (let source of program !.getSourceFiles()) { - expectNoDiagnostics(service.getSyntacticDiagnostics(source.fileName)); - expectNoDiagnostics(service.getSemanticDiagnostics(source.fileName)); +describe('plugin', () => { + const mockHost = new MockTypescriptHost(['/app/main.ts']); + const tsLS = ts.createLanguageService(mockHost); + const program = tsLS.getProgram() !; + const plugin = create({ + languageService: tsLS, + languageServiceHost: mockHost, + project: mockProject, + serverHost: {} as any, + config: {}, + }); + + beforeEach(() => { mockHost.reset(); }); + + it('should produce TypeScript diagnostics', () => { + const fileName = '/foo.ts'; + mockHost.addScript(fileName, ` + function add(x: number) { + return x + 42; + } + add('hello'); + `); + const diags = plugin.getSemanticDiagnostics(fileName); + expect(diags.length).toBe(1); + expect(diags[0].messageText) + .toBe(`Argument of type '"hello"' is not assignable to parameter of type 'number'.`); + }); + + it('should not report TypeScript errors on tour of heroes', () => { + const compilerDiags = tsLS.getCompilerOptionsDiagnostics(); + expect(compilerDiags).toEqual([]); + const sourceFiles = program.getSourceFiles().filter(f => !f.fileName.endsWith('.d.ts')); + // there are six .ts files in the test project + expect(sourceFiles.length).toBe(6); + for (const {fileName} of sourceFiles) { + const syntacticDiags = tsLS.getSyntacticDiagnostics(fileName); + expect(syntacticDiags).toEqual([]); + const semanticDiags = tsLS.getSemanticDiagnostics(fileName); + expect(semanticDiags).toEqual([]); } }); + it('should return external templates as external files', () => { + const externalFiles = getExternalFiles(mockProject); + expect(externalFiles).toEqual(['/app/test.ng']); + }); + it('should not report template errors on tour of heroes', () => { - for (let source of program !.getSourceFiles()) { - // Ignore all 'cases.ts' files as they intentionally contain errors. - if (!source.fileName.endsWith('cases.ts')) { - expectNoDiagnostics(plugin.getSemanticDiagnostics(source.fileName)); + const filesWithTemplates = [ + // Ignore all '*-cases.ts' files as they intentionally contain errors. + '/app/app.component.ts', + ]; + for (const fileName of filesWithTemplates) { + const diags = plugin.getSemanticDiagnostics(fileName); + expect(diags).toEqual([]); + } + }); + + it('should respect paths configuration', () => { + const SHARED_MODULE = '/app/foo/bar/shared.ts'; + const MY_COMPONENT = '/app/my.component.ts'; + mockHost.overrideOptions({ + baseUrl: '/app', + paths: {'bar/*': ['foo/bar/*']}, + }); + mockHost.addScript(SHARED_MODULE, ` + export interface Node { + children: Node[]; } + `); + mockHost.addScript(MY_COMPONENT, ` + import { Component, NgModule } from '@angular/core'; + import { Node } from 'bar/shared'; + + @Component({ + selector: 'my-component', + template: '{{ tree.~{tree}children }}' + }) + export class MyComponent { + tree: Node = { + children: [], + }; + } + + @NgModule({ + declarations: [MyComponent], + }) + export class MyModule {} + `); + // First, make sure there are no errors in newly added scripts. + for (const fileName of [SHARED_MODULE, MY_COMPONENT]) { + const syntacticDiags = plugin.getSyntacticDiagnostics(fileName); + expect(syntacticDiags).toEqual([]); + const semanticDiags = plugin.getSemanticDiagnostics(fileName); + expect(semanticDiags).toEqual([]); } + const marker = mockHost.getLocationMarkerFor(MY_COMPONENT, 'tree'); + const completions = plugin.getCompletionsAtPosition(MY_COMPONENT, marker.start, undefined); + expect(completions).toBeDefined(); + expect(completions !.entries).toEqual([ + { + name: 'children', + kind: CompletionKind.PROPERTY as any, + sortText: 'children', + }, + ]); }); - - it('should be able to get entity completions', - () => { contains('/app/app.component.ts', 'entity-amp', '&', '>', '<', 'ι'); }); - - it('should be able to return html elements', () => { - let htmlTags = ['h1', 'h2', 'div', 'span']; - let locations = ['empty', 'start-tag-h1', 'h1-content', 'start-tag', 'start-tag-after-h']; - for (let location of locations) { - contains('/app/app.component.ts', location, ...htmlTags); - } - }); - - it('should be able to return element directives', - () => { contains('/app/app.component.ts', 'empty', 'my-app'); }); - - it('should be able to return h1 attributes', () => { - contains('/app/app.component.ts', 'h1-after-space', 'id', 'dir', 'lang', 'onclick'); - }); - - it('should be able to find common angular attributes', () => { - contains('/app/app.component.ts', 'div-attributes', '(click)', '[ngClass]', '*ngIf', '*ngFor'); - }); - - it('should be able to return attribute names with an incompete attribute', - () => { contains('/app/parsing-cases.ts', 'no-value-attribute', 'id', 'dir', 'lang'); }); - - it('should be able to return attributes of an incomplete element', () => { - contains('/app/parsing-cases.ts', 'incomplete-open-lt', 'a'); - contains('/app/parsing-cases.ts', 'incomplete-open-a', 'a'); - contains('/app/parsing-cases.ts', 'incomplete-open-attr', 'id', 'dir', 'lang'); - }); - - it('should be able to return completions with a missing closing tag', - () => { contains('/app/parsing-cases.ts', 'missing-closing', 'h1', 'h2'); }); - - it('should be able to return common attributes of an unknown tag', - () => { contains('/app/parsing-cases.ts', 'unknown-element', 'id', 'dir', 'lang'); }); - - it('should be able to get the completions at the beginning of an interpolation', - () => { contains('/app/app.component.ts', 'h2-hero', 'hero', 'title'); }); - - it('should not include private members of a class', - () => { contains('/app/app.component.ts', 'h2-hero', '-internal'); }); - - it('should be able to get the completions at the end of an interpolation', - () => { contains('/app/app.component.ts', 'sub-end', 'hero', 'title'); }); - - it('should be able to get the completions in a property', - () => { contains('/app/app.component.ts', 'h2-name', 'name', 'id'); }); - - it('should be able to get a list of pipe values', () => { - contains('/app/parsing-cases.ts', 'before-pipe', 'lowercase', 'uppercase'); - contains('/app/parsing-cases.ts', 'in-pipe', 'lowercase', 'uppercase'); - contains('/app/parsing-cases.ts', 'after-pipe', 'lowercase', 'uppercase'); - }); - - it('should be able to get completions in an empty interpolation', - () => { contains('/app/parsing-cases.ts', 'empty-interpolation', 'title', 'subTitle'); }); - - describe('with attributes', () => { - it('should be able to complete property value', - () => { contains('/app/parsing-cases.ts', 'property-binding-model', 'test'); }); - it('should be able to complete an event', - () => { contains('/app/parsing-cases.ts', 'event-binding-model', 'modelChanged'); }); - it('should be able to complete a two-way binding', - () => { contains('/app/parsing-cases.ts', 'two-way-binding-model', 'test'); }); - }); - - describe('with a *ngFor', () => { - it('should include a let for empty attribute', - () => { contains('/app/parsing-cases.ts', 'for-empty', 'let'); }); - it('should suggest NgForRow members for let initialization expression', () => { - contains( - '/app/parsing-cases.ts', 'for-let-i-equal', 'index', 'count', 'first', 'last', 'even', - 'odd'); - }); - it('should include a let', () => { contains('/app/parsing-cases.ts', 'for-let', 'let'); }); - it('should include an "of"', () => { contains('/app/parsing-cases.ts', 'for-of', 'of'); }); - it('should include field reference', - () => { contains('/app/parsing-cases.ts', 'for-people', 'people'); }); - it('should include person in the let scope', - () => { contains('/app/parsing-cases.ts', 'for-interp-person', 'person'); }); - // TODO: Enable when we can infer the element type of the ngFor - // it('should include determine person\'s type as Person', () => { - // contains('/app/parsing-cases.ts', 'for-interp-name', 'name', 'age'); - // contains('/app/parsing-cases.ts', 'for-interp-age', 'name', 'age'); - // }); - }); - - describe('for pipes', () => { - it('should be able to resolve lowercase', - () => { contains('/app/expression-cases.ts', 'string-pipe', 'substring'); }); - }); - - describe('with references', () => { - it('should list references', - () => { contains('/app/parsing-cases.ts', 'test-comp-content', 'test1', 'test2', 'div'); }); - it('should reference the component', - () => { contains('/app/parsing-cases.ts', 'test-comp-after-test', 'name'); }); - // TODO: Enable when we have a flag that indicates the project targets the DOM - // it('should reference the element if no component', () => { - // contains('/app/parsing-cases.ts', 'test-comp-after-div', 'innerText'); - // }); - }); - - describe('for semantic errors', () => { - describe(`with config 'angularOnly = true`, () => { - const ngLS = createPlugin(service, mockHost, {angularOnly: true}); - it('should not report template errors on TOH', () => { - const sourceFiles = ngLS.getProgram() !.getSourceFiles(); - expect(sourceFiles.length).toBeGreaterThan(0); - for (const {fileName} of sourceFiles) { - // Ignore all 'cases.ts' files as they intentionally contain errors. - if (!fileName.endsWith('cases.ts')) { - expectNoDiagnostics(ngLS.getSemanticDiagnostics(fileName)); - } - } - }); - - it('should be able to get entity completions', () => { - const fileName = '/app/app.component.ts'; - const marker = mockHost.getLocationMarkerFor(fileName, 'entity-amp'); - const results = ngLS.getCompletionsAtPosition(fileName, marker.start, {} /* options */); - expect(results).toBeTruthy(); - expectEntries('entity-amp', results !, ...['&', '>', '<', 'ι']); - }); - - it('should report template diagnostics', () => { - // TODO(kyliau): Rename these to end with '-error.ts' - const fileName = '/app/expression-cases.ts'; - const diagnostics = ngLS.getSemanticDiagnostics(fileName); - expect(diagnostics.map(d => d.messageText)).toEqual([ - `Identifier 'foo' is not defined. The component declaration, template variable declarations, and element references do not contain such a member`, - `Identifier 'nam' is not defined. 'Person' does not contain such a member`, - `Identifier 'myField' refers to a private member of the component`, - `Expected a numeric type`, - ]); - }); - }); - }); - - function createPlugin(tsLS: ts.LanguageService, tsLSHost: ts.LanguageServiceHost, config = {}) { - const project = {projectService: {logger: {info() {}}}}; - return create({ - languageService: tsLS, - languageServiceHost: tsLSHost, - project: project as any, - serverHost: {} as any, - config: {...config}, - }); - } - - function contains(fileName: string, locationMarker: string, ...names: string[]) { - const marker = mockHost.getLocationMarkerFor(fileName, locationMarker); - expectEntries( - locationMarker, plugin.getCompletionsAtPosition(fileName, marker.start, undefined) !, - ...names); - } }); +describe(`with config 'angularOnly = true`, () => { + const mockHost = new MockTypescriptHost(['/app/main.ts']); + const tsLS = ts.createLanguageService(mockHost); + const plugin = create({ + languageService: tsLS, + languageServiceHost: mockHost, + project: mockProject, + serverHost: {} as any, + config: { + angularOnly: true, + }, + }); -function expectEntries(locationMarker: string, info: ts.CompletionInfo, ...names: string[]) { - let entries: {[name: string]: boolean} = {}; - if (!info) { - throw new Error(`Expected result from ${locationMarker} to include ${names.join( - ', ')} but no result provided`); - } else { - for (let entry of info.entries) { - entries[entry.name] = true; - } - let shouldContains = names.filter(name => !name.startsWith('-')); - let shouldNotContain = names.filter(name => name.startsWith('-')); - let missing = shouldContains.filter(name => !entries[name]); - let present = shouldNotContain.map(name => name.substr(1)).filter(name => entries[name]); - if (missing.length) { - throw new Error(`Expected result from ${locationMarker - } to include at least one of the following, ${missing - .join(', ')}, in the list of entries ${info.entries.map(entry => entry.name) - .join(', ')}`); - } - if (present.length) { - throw new Error(`Unexpected member${present.length > 1 ? 's' : - '' - } included in result: ${present.join(', ')}`); - } - } -} + it('should not produce TypeScript diagnostics', () => { + const fileName = '/foo.ts'; + mockHost.addScript(fileName, ` + function add(x: number) { + return x + 42; + } + add('hello'); + `); + const diags = plugin.getSemanticDiagnostics(fileName); + expect(diags).toEqual([]); + }); -function expectNoDiagnostics(diagnostics: ts.Diagnostic[]) { - for (const diagnostic of diagnostics) { - let message = ts.flattenDiagnosticMessageText(diagnostic.messageText, '\n'); - if (diagnostic.file && diagnostic.start) { - let {line, character} = diagnostic.file.getLineAndCharacterOfPosition(diagnostic.start); - console.error(`${diagnostic.file.fileName} (${line + 1},${character + 1}): ${message}`); - } else { - console.error(`${message}`); + it('should not report template errors on TOH', () => { + const filesWithTemplates = [ + // Ignore all '*-cases.ts' files as they intentionally contain errors. + '/app/app.component.ts', + '/app/test.ng', + ]; + for (const fileName of filesWithTemplates) { + const diags = plugin.getSemanticDiagnostics(fileName); + expect(diags).toEqual([]); } - } - expect(diagnostics.length).toBe(0); -} + }); +});