test(language-service): introduce new virtual testing environment (#39594)

This commit adds new language service testing infrastructure which allows
for in-memory testing. It solves a number of issues with the previous
testing infrastructure that relied on a single integration project across
all of the tests, and also provides for much faster builds by using
the compiler-cli's mock versions of @angular/core and @angular/common.

A new `LanguageServiceTestEnvironment` class (conceptually mirroring the
compiler-cli `NgtscTestEnvironment`) controls setup and execution of tests.
The `FileSystem` abstraction is used to drive a `ts.server.ServerHost`,
which backs the language service infrastructure.

Since many language service tests revolve around the template, the API is
currently optimized to spin up a "skeleton" project and then override its
template for each test.

The existing Quick Info tests (quick_info_spec.ts) were ported to the new
infrastructure for validation. The tests were cleaned up a bit to remove
unnecessary initializations as well as correct legitimate template errors
which did not affect the test outcome, but caused additional validation of
test correctness to fail. They still utilize a shared project with all
fields required for each individual unit test, which is an anti-pattern, but
new tests can now easily be written independently without relying on the
shared project, which was extremely difficult previously. Future cleanup
work might refactor these tests to be more independent.

PR Close #39594
This commit is contained in:
Alex Rickabaugh 2020-11-04 12:28:59 -08:00 committed by atscott
parent a7155bc2fa
commit b6893d23c5
6 changed files with 528 additions and 66 deletions

View File

@ -15,6 +15,15 @@ import * as ts from 'typescript/lib/tsserverlibrary';
import {LanguageServiceAdapter} from './language_service_adapter'; import {LanguageServiceAdapter} from './language_service_adapter';
import {isExternalTemplate} from './utils'; import {isExternalTemplate} from './utils';
/**
* Manages the `NgCompiler` instance which backs the language service, updating or replacing it as
* needed to produce an up-to-date understanding of the current program.
*
* TODO(alxhub): currently the options used for the compiler are specified at `CompilerFactory`
* construction, and are not changable. In a real project, users can update `tsconfig.json`. We need
* to properly handle a change in the compiler options, either by having an API to update the
* `CompilerFactory` to use new options, or by replacing it entirely.
*/
export class CompilerFactory { export class CompilerFactory {
private readonly incrementalStrategy = new TrackedIncrementalBuildStrategy(); private readonly incrementalStrategy = new TrackedIncrementalBuildStrategy();
private compiler: NgCompiler|null = null; private compiler: NgCompiler|null = null;
@ -23,21 +32,15 @@ export class CompilerFactory {
constructor( constructor(
private readonly adapter: LanguageServiceAdapter, private readonly adapter: LanguageServiceAdapter,
private readonly programStrategy: TypeCheckingProgramStrategy, private readonly programStrategy: TypeCheckingProgramStrategy,
private readonly options: NgCompilerOptions,
) {} ) {}
/** getOrCreate(): NgCompiler {
* Create a new instance of the Ivy compiler if the program has changed since
* the last time the compiler was instantiated. If the program has not changed,
* return the existing instance.
* @param fileName override the template if this is an external template file
* @param options angular compiler options
*/
getOrCreateWithChangedFile(fileName: string, options: NgCompilerOptions): NgCompiler {
const program = this.programStrategy.getProgram(); const program = this.programStrategy.getProgram();
if (!this.compiler || program !== this.lastKnownProgram) { if (this.compiler === null || program !== this.lastKnownProgram) {
this.compiler = new NgCompiler( this.compiler = new NgCompiler(
this.adapter, // like compiler host this.adapter, // like compiler host
options, // angular compiler options this.options, // angular compiler options
program, program,
this.programStrategy, this.programStrategy,
this.incrementalStrategy, this.incrementalStrategy,
@ -47,12 +50,24 @@ export class CompilerFactory {
); );
this.lastKnownProgram = program; this.lastKnownProgram = program;
} }
if (isExternalTemplate(fileName)) {
this.overrideTemplate(fileName, this.compiler);
}
return this.compiler; return this.compiler;
} }
/**
* Create a new instance of the Ivy compiler if the program has changed since
* the last time the compiler was instantiated. If the program has not changed,
* return the existing instance.
* @param fileName override the template if this is an external template file
* @param options angular compiler options
*/
getOrCreateWithChangedFile(fileName: string): NgCompiler {
const compiler = this.getOrCreate();
if (isExternalTemplate(fileName)) {
this.overrideTemplate(fileName, compiler);
}
return compiler;
}
private overrideTemplate(fileName: string, compiler: NgCompiler) { private overrideTemplate(fileName: string, compiler: NgCompiler) {
if (!this.adapter.isTemplateDirty(fileName)) { if (!this.adapter.isTemplateDirty(fileName)) {
return; return;

View File

@ -21,7 +21,7 @@ import {getTemplateInfoAtPosition, isTypeScriptFile} from './utils';
export class LanguageService { export class LanguageService {
private options: CompilerOptions; private options: CompilerOptions;
private readonly compilerFactory: CompilerFactory; readonly compilerFactory: CompilerFactory;
private readonly strategy: TypeCheckingProgramStrategy; private readonly strategy: TypeCheckingProgramStrategy;
private readonly adapter: LanguageServiceAdapter; private readonly adapter: LanguageServiceAdapter;
@ -29,12 +29,12 @@ export class LanguageService {
this.options = parseNgCompilerOptions(project); this.options = parseNgCompilerOptions(project);
this.strategy = createTypeCheckingProgramStrategy(project); this.strategy = createTypeCheckingProgramStrategy(project);
this.adapter = new LanguageServiceAdapter(project); this.adapter = new LanguageServiceAdapter(project);
this.compilerFactory = new CompilerFactory(this.adapter, this.strategy); this.compilerFactory = new CompilerFactory(this.adapter, this.strategy, this.options);
this.watchConfigFile(project); this.watchConfigFile(project);
} }
getSemanticDiagnostics(fileName: string): ts.Diagnostic[] { getSemanticDiagnostics(fileName: string): ts.Diagnostic[] {
const compiler = this.compilerFactory.getOrCreateWithChangedFile(fileName, this.options); const compiler = this.compilerFactory.getOrCreateWithChangedFile(fileName);
const ttc = compiler.getTemplateTypeChecker(); const ttc = compiler.getTemplateTypeChecker();
const diagnostics: ts.Diagnostic[] = []; const diagnostics: ts.Diagnostic[] = [];
if (isTypeScriptFile(fileName)) { if (isTypeScriptFile(fileName)) {
@ -57,7 +57,7 @@ export class LanguageService {
getDefinitionAndBoundSpan(fileName: string, position: number): ts.DefinitionInfoAndBoundSpan getDefinitionAndBoundSpan(fileName: string, position: number): ts.DefinitionInfoAndBoundSpan
|undefined { |undefined {
const compiler = this.compilerFactory.getOrCreateWithChangedFile(fileName, this.options); const compiler = this.compilerFactory.getOrCreateWithChangedFile(fileName);
const results = const results =
new DefinitionBuilder(this.tsLS, compiler).getDefinitionAndBoundSpan(fileName, position); new DefinitionBuilder(this.tsLS, compiler).getDefinitionAndBoundSpan(fileName, position);
this.compilerFactory.registerLastKnownProgram(); this.compilerFactory.registerLastKnownProgram();
@ -66,7 +66,7 @@ export class LanguageService {
getTypeDefinitionAtPosition(fileName: string, position: number): getTypeDefinitionAtPosition(fileName: string, position: number):
readonly ts.DefinitionInfo[]|undefined { readonly ts.DefinitionInfo[]|undefined {
const compiler = this.compilerFactory.getOrCreateWithChangedFile(fileName, this.options); const compiler = this.compilerFactory.getOrCreateWithChangedFile(fileName);
const results = const results =
new DefinitionBuilder(this.tsLS, compiler).getTypeDefinitionsAtPosition(fileName, position); new DefinitionBuilder(this.tsLS, compiler).getTypeDefinitionsAtPosition(fileName, position);
this.compilerFactory.registerLastKnownProgram(); this.compilerFactory.registerLastKnownProgram();
@ -75,7 +75,7 @@ export class LanguageService {
getQuickInfoAtPosition(fileName: string, position: number): ts.QuickInfo|undefined { getQuickInfoAtPosition(fileName: string, position: number): ts.QuickInfo|undefined {
const program = this.strategy.getProgram(); const program = this.strategy.getProgram();
const compiler = this.compilerFactory.getOrCreateWithChangedFile(fileName, this.options); const compiler = this.compilerFactory.getOrCreateWithChangedFile(fileName);
const templateInfo = getTemplateInfoAtPosition(fileName, position, compiler); const templateInfo = getTemplateInfoAtPosition(fileName, position, compiler);
if (templateInfo === undefined) { if (templateInfo === undefined) {
return undefined; return undefined;

View File

@ -0,0 +1,31 @@
load("//tools:defaults.bzl", "jasmine_node_test", "ts_library")
ts_library(
name = "test_lib",
testonly = True,
srcs = glob(["*.ts"]),
deps = [
"//packages/compiler",
"//packages/compiler-cli/src/ngtsc/core:api",
"//packages/compiler-cli/src/ngtsc/file_system",
"//packages/compiler-cli/src/ngtsc/file_system/testing",
"//packages/compiler-cli/src/ngtsc/testing",
"//packages/compiler-cli/src/ngtsc/typecheck/api",
"//packages/language-service/ivy",
"@npm//typescript",
],
)
jasmine_node_test(
name = "test",
data = [
"//packages/compiler-cli/src/ngtsc/testing/fake_common:npm_package",
"//packages/compiler-cli/src/ngtsc/testing/fake_core:npm_package",
],
tags = [
"ivy-only",
],
deps = [
":test_lib",
],
)

View File

@ -0,0 +1,219 @@
/**
* @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 {TmplAstNode} from '@angular/compiler';
import {StrictTemplateOptions} from '@angular/compiler-cli/src/ngtsc/core/api';
import {absoluteFrom, AbsoluteFsPath, FileSystem, getFileSystem, getSourceFileOrError} from '@angular/compiler-cli/src/ngtsc/file_system';
import {MockFileSystem, TestFile} from '@angular/compiler-cli/src/ngtsc/file_system/testing';
import {loadStandardTestFiles} from '@angular/compiler-cli/src/ngtsc/testing';
import {TemplateTypeChecker} from '@angular/compiler-cli/src/ngtsc/typecheck/api';
import * as ts from 'typescript/lib/tsserverlibrary';
import {LanguageService} from '../language_service';
import {MockServerHost} from './mock_host';
function writeTsconfig(
fs: FileSystem, entryFiles: AbsoluteFsPath[], options: TestableOptions): void {
fs.writeFile(
absoluteFrom('/tsconfig.json'),
JSON.stringify(
{
compilerOptions: {
strict: true,
experimentalDecorators: true,
moduleResolution: 'node',
target: 'es2015',
lib: [
'dom',
'es2015',
],
},
files: entryFiles,
angularCompilerOptions: {
strictTemplates: true,
...options,
}
},
null, 2));
}
export type TestableOptions = StrictTemplateOptions;
export class LanguageServiceTestEnvironment {
private constructor(private tsLS: ts.LanguageService, readonly ngLS: LanguageService) {}
static setup(files: TestFile[], options: TestableOptions = {}): LanguageServiceTestEnvironment {
const fs = getFileSystem();
if (!(fs instanceof MockFileSystem)) {
throw new Error(`LanguageServiceTestEnvironment only works with a mock filesystem`);
}
fs.init(loadStandardTestFiles({
fakeCommon: true,
}));
const host = new MockServerHost(fs);
const tsconfigPath = absoluteFrom('/tsconfig.json');
const entryFiles: AbsoluteFsPath[] = [];
for (const {name, contents, isRoot} of files) {
fs.writeFile(name, contents);
if (isRoot === true) {
entryFiles.push(name);
}
}
if (entryFiles.length === 0) {
throw new Error(`Expected at least one root TestFile.`);
}
writeTsconfig(fs, files.filter(file => file.isRoot === true).map(file => file.name), options);
const projectService = new ts.server.ProjectService({
host,
logger,
cancellationToken: ts.server.nullCancellationToken,
useSingleInferredProject: true,
useInferredProjectPerProjectRoot: true,
typingsInstaller: ts.server.nullTypingsInstaller,
});
// Open all root files.
for (const entryFile of entryFiles) {
projectService.openClientFile(entryFile);
}
const project = projectService.findProject(tsconfigPath);
if (project === undefined) {
throw new Error(`Failed to create project for ${tsconfigPath}`);
}
// The following operation forces a ts.Program to be created.
const tsLS = project.getLanguageService();
const ngLS = new LanguageService(project, tsLS);
return new LanguageServiceTestEnvironment(tsLS, ngLS);
}
getClass(fileName: AbsoluteFsPath, className: string): ts.ClassDeclaration {
const program = this.tsLS.getProgram();
if (program === undefined) {
throw new Error(`Expected to get a ts.Program`);
}
const sf = getSourceFileOrError(program, fileName);
return getClassOrError(sf, className);
}
overrideTemplateWithCursor(fileName: AbsoluteFsPath, className: string, contents: string):
{cursor: number, nodes: TmplAstNode[], component: ts.ClassDeclaration, text: string} {
const program = this.tsLS.getProgram();
if (program === undefined) {
throw new Error(`Expected to get a ts.Program`);
}
const sf = getSourceFileOrError(program, fileName);
const component = getClassOrError(sf, className);
const ngCompiler = this.ngLS.compilerFactory.getOrCreate();
const templateTypeChecker = ngCompiler.getTemplateTypeChecker();
const {cursor, text} = extractCursorInfo(contents);
const {nodes} = templateTypeChecker.overrideComponentTemplate(component, text);
return {cursor, nodes, component, text};
}
expectNoSourceDiagnostics(): void {
const program = this.tsLS.getProgram();
if (program === undefined) {
throw new Error(`Expected to get a ts.Program`);
}
const ngCompiler = this.ngLS.compilerFactory.getOrCreate();
for (const sf of program.getSourceFiles()) {
if (sf.isDeclarationFile || sf.fileName.endsWith('.ngtypecheck.ts')) {
continue;
}
const syntactic = program.getSyntacticDiagnostics(sf);
expect(syntactic.map(diag => diag.messageText)).toEqual([]);
if (syntactic.length > 0) {
continue;
}
const semantic = program.getSemanticDiagnostics(sf);
expect(semantic.map(diag => diag.messageText)).toEqual([]);
if (semantic.length > 0) {
continue;
}
const ngDiagnostics = ngCompiler.getDiagnostics(sf);
expect(ngDiagnostics.map(diag => diag.messageText)).toEqual([]);
}
this.ngLS.compilerFactory.registerLastKnownProgram();
}
expectNoTemplateDiagnostics(fileName: AbsoluteFsPath, className: string): void {
const program = this.tsLS.getProgram();
if (program === undefined) {
throw new Error(`Expected to get a ts.Program`);
}
const sf = getSourceFileOrError(program, fileName);
const component = getClassOrError(sf, className);
const diags = this.getTemplateTypeChecker().getDiagnosticsForComponent(component);
this.ngLS.compilerFactory.registerLastKnownProgram();
expect(diags.map(diag => diag.messageText)).toEqual([]);
}
getTemplateTypeChecker(): TemplateTypeChecker {
return this.ngLS.compilerFactory.getOrCreate().getTemplateTypeChecker();
}
}
const logger: ts.server.Logger = {
close(): void{},
hasLevel(level: ts.server.LogLevel): boolean {
return false;
},
loggingEnabled(): boolean {
return false;
},
perftrc(s: string): void{},
info(s: string): void{},
startGroup(): void{},
endGroup(): void{},
msg(s: string, type?: ts.server.Msg): void{},
getLogFileName(): string |
undefined {
return;
},
};
function getClassOrError(sf: ts.SourceFile, name: string): ts.ClassDeclaration {
for (const stmt of sf.statements) {
if (ts.isClassDeclaration(stmt) && stmt.name !== undefined && stmt.name.text === name) {
return stmt;
}
}
throw new Error(`Class ${name} not found in file: ${sf.fileName}: ${sf.text}`);
}
function extractCursorInfo(textWithCursor: string): {cursor: number, text: string} {
const cursor = textWithCursor.indexOf('¦');
if (cursor === -1) {
throw new Error(`Expected to find cursor symbol '¦'`);
}
return {
cursor,
text: textWithCursor.substr(0, cursor) + textWithCursor.substr(cursor + 1),
};
}

View File

@ -0,0 +1,121 @@
/**
* @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 {absoluteFrom} from '@angular/compiler-cli/src/ngtsc/file_system';
import {MockFileSystem} from '@angular/compiler-cli/src/ngtsc/file_system/testing';
import * as ts from 'typescript/lib/tsserverlibrary';
const NOOP_FILE_WATCHER: ts.FileWatcher = {
close() {}
};
/**
* Adapts from the `ts.server.ServerHost` API to an in-memory filesystem.
*/
export class MockServerHost implements ts.server.ServerHost {
constructor(private fs: MockFileSystem) {}
get newLine(): string {
return '\n';
}
get useCaseSensitiveFileNames(): boolean {
return this.fs.isCaseSensitive();
}
readFile(path: string, encoding?: string): string|undefined {
return this.fs.readFile(absoluteFrom(path));
}
resolvePath(path: string): string {
return this.fs.resolve(path);
}
fileExists(path: string): boolean {
const absPath = absoluteFrom(path);
return this.fs.exists(absPath) && this.fs.lstat(absPath).isFile();
}
directoryExists(path: string): boolean {
const absPath = absoluteFrom(path);
return this.fs.exists(absPath) && this.fs.lstat(absPath).isDirectory();
}
createDirectory(path: string): void {
this.fs.ensureDir(absoluteFrom(path));
}
getExecutingFilePath(): string {
// This is load-bearing, as TypeScript uses the result of this call to locate the directory in
// which it expects to find .d.ts files for the "standard libraries" - DOM, ES2015, etc.
return '/node_modules/typescript/lib/tsserver.js';
}
getCurrentDirectory(): string {
return '/';
}
createHash(data: string): string {
return ts.sys.createHash!(data);
}
get args(): string[] {
throw new Error('Property not implemented.');
}
watchFile(
path: string, callback: ts.FileWatcherCallback, pollingInterval?: number,
options?: ts.WatchOptions): ts.FileWatcher {
return NOOP_FILE_WATCHER;
}
watchDirectory(
path: string, callback: ts.DirectoryWatcherCallback, recursive?: boolean,
options?: ts.WatchOptions): ts.FileWatcher {
return NOOP_FILE_WATCHER;
}
setTimeout(callback: (...args: any[]) => void, ms: number, ...args: any[]) {
throw new Error('Method not implemented.');
}
clearTimeout(timeoutId: any): void {
throw new Error('Method not implemented.');
}
setImmediate(callback: (...args: any[]) => void, ...args: any[]) {
throw new Error('Method not implemented.');
}
clearImmediate(timeoutId: any): void {
throw new Error('Method not implemented.');
}
write(s: string): void {
throw new Error('Method not implemented.');
}
writeFile(path: string, data: string, writeByteOrderMark?: boolean): void {
throw new Error('Method not implemented.');
}
getDirectories(path: string): string[] {
throw new Error('Method not implemented.');
}
readDirectory(
path: string, extensions?: readonly string[], exclude?: readonly string[],
include?: readonly string[], depth?: number): string[] {
throw new Error('Method not implemented.');
}
exit(exitCode?: number): void {
throw new Error('Method not implemented.');
}
}

View File

@ -6,24 +6,107 @@
* found in the LICENSE file at https://angular.io/license * found in the LICENSE file at https://angular.io/license
*/ */
import {absoluteFrom} from '@angular/compiler-cli/src/ngtsc/file_system';
import {initMockFileSystem, TestFile} from '@angular/compiler-cli/src/ngtsc/file_system/testing';
import * as ts from 'typescript/lib/tsserverlibrary'; import * as ts from 'typescript/lib/tsserverlibrary';
import {LanguageService} from '../../language_service'; import {LanguageServiceTestEnvironment} from './env';
import {APP_COMPONENT, MockService, setup, TEST_TEMPLATE} from './mock_host'; function quickInfoSkeleton(): TestFile[] {
return [
{
name: absoluteFrom('/app.ts'),
contents: `
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'}];
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 {}
`,
isRoot: true,
},
{
name: absoluteFrom('/app.html'),
contents: `Will be overridden`,
}
];
}
describe('quick info', () => { describe('quick info', () => {
let service: MockService; let env: LanguageServiceTestEnvironment;
let ngLS: LanguageService;
beforeAll(() => {
const {project, service: _service, tsLS} = setup();
service = _service;
ngLS = new LanguageService(project, tsLS);
});
beforeEach(() => { beforeEach(() => {
service.reset(); initMockFileSystem('Native');
env = LanguageServiceTestEnvironment.setup(quickInfoSkeleton());
}); });
describe('elements', () => { describe('elements', () => {
@ -78,24 +161,23 @@ describe('quick info', () => {
const {documentation} = expectQuickInfo({ const {documentation} = expectQuickInfo({
templateOverride: `<div *¦ngFor="let item of heroes"></div>`, templateOverride: `<div *¦ngFor="let item of heroes"></div>`,
expectedSpanText: 'ngFor', expectedSpanText: 'ngFor',
expectedDisplayString: '(directive) NgForOf<Hero, Array<Hero>>' expectedDisplayString: '(directive) NgForOf<Hero, Hero[]>'
}); });
expect(toText(documentation)) expect(toText(documentation)).toContain('A fake version of the NgFor directive.');
.toContain('A [structural directive](guide/structural-directives) that renders');
}); });
it('should work for directives with compound selectors, some of which are bindings', () => { it('should work for directives with compound selectors, some of which are bindings', () => {
expectQuickInfo({ expectQuickInfo({
templateOverride: `<ng-template ngF¦or let-hero [ngForOf]="heroes">{{item}}</ng-template>`, templateOverride: `<ng-template ngF¦or let-hero [ngForOf]="heroes">{{hero}}</ng-template>`,
expectedSpanText: 'ngFor', expectedSpanText: 'ngFor',
expectedDisplayString: '(directive) NgForOf<Hero, Array<Hero>>' expectedDisplayString: '(directive) NgForOf<Hero, Hero[]>'
}); });
}); });
it('should work for data-let- syntax', () => { it('should work for data-let- syntax', () => {
expectQuickInfo({ expectQuickInfo({
templateOverride: templateOverride:
`<ng-template ngFor data-let-he¦ro [ngForOf]="heroes">{{item}}</ng-template>`, `<ng-template ngFor data-let-he¦ro [ngForOf]="heroes">{{hero}}</ng-template>`,
expectedSpanText: 'hero', expectedSpanText: 'hero',
expectedDisplayString: '(variable) hero: Hero' expectedDisplayString: '(variable) hero: Hero'
}); });
@ -127,7 +209,7 @@ describe('quick info', () => {
it('should work for structural directive inputs ngForTrackBy', () => { it('should work for structural directive inputs ngForTrackBy', () => {
expectQuickInfo({ expectQuickInfo({
templateOverride: `<div *ngFor="let item of heroes; tr¦ackBy: test;"></div>`, templateOverride: `<div *ngFor="let item of heroes; tr¦ackBy: trackByFn;"></div>`,
expectedSpanText: 'trackBy', expectedSpanText: 'trackBy',
expectedDisplayString: expectedDisplayString:
'(property) NgForOf<Hero, Hero[]>.ngForTrackBy: TrackByFunction<Hero>' '(property) NgForOf<Hero, Hero[]>.ngForTrackBy: TrackByFunction<Hero>'
@ -136,7 +218,7 @@ describe('quick info', () => {
it('should work for structural directive inputs ngForOf', () => { it('should work for structural directive inputs ngForOf', () => {
expectQuickInfo({ expectQuickInfo({
templateOverride: `<div *ngFor="let item o¦f heroes; trackBy: test;"></div>`, templateOverride: `<div *ngFor="let item o¦f heroes; trackBy: trackByFn;"></div>`,
expectedSpanText: 'of', expectedSpanText: 'of',
expectedDisplayString: expectedDisplayString:
'(property) NgForOf<Hero, Hero[]>.ngForOf: Hero[] | (Hero[] & Iterable<Hero>) | null | undefined' '(property) NgForOf<Hero, Hero[]>.ngForOf: Hero[] | (Hero[] & Iterable<Hero>) | null | undefined'
@ -157,7 +239,7 @@ describe('quick info', () => {
expectQuickInfo({ expectQuickInfo({
templateOverride: `<test-comp (te¦st)="myClick($event)"></test-comp>`, templateOverride: `<test-comp (te¦st)="myClick($event)"></test-comp>`,
expectedSpanText: 'test', expectedSpanText: 'test',
expectedDisplayString: '(event) TestComponent.testEvent: EventEmitter<any>' expectedDisplayString: '(event) TestComponent.testEvent: EventEmitter<string>'
}); });
}); });
@ -165,12 +247,12 @@ describe('quick info', () => {
expectQuickInfo({ expectQuickInfo({
templateOverride: `<test-comp on-te¦st="myClick($event)"></test-comp>`, templateOverride: `<test-comp on-te¦st="myClick($event)"></test-comp>`,
expectedSpanText: 'test', expectedSpanText: 'test',
expectedDisplayString: '(event) TestComponent.testEvent: EventEmitter<any>' expectedDisplayString: '(event) TestComponent.testEvent: EventEmitter<string>'
}); });
expectQuickInfo({ expectQuickInfo({
templateOverride: `<test-comp data-on-te¦st="myClick($event)"></test-comp>`, templateOverride: `<test-comp data-on-te¦st="myClick($event)"></test-comp>`,
expectedSpanText: 'test', expectedSpanText: 'test',
expectedDisplayString: '(event) TestComponent.testEvent: EventEmitter<any>' expectedDisplayString: '(event) TestComponent.testEvent: EventEmitter<string>'
}); });
}); });
@ -272,7 +354,7 @@ describe('quick info', () => {
expectQuickInfo({ expectQuickInfo({
templateOverride: `<div>{{ tit¦le }}</div>`, templateOverride: `<div>{{ tit¦le }}</div>`,
expectedSpanText: 'title', expectedSpanText: 'title',
expectedDisplayString: '(property) AppComponent.title: string' expectedDisplayString: '(property) AppCmp.title: string'
}); });
}); });
@ -288,7 +370,7 @@ describe('quick info', () => {
expectQuickInfo({ expectQuickInfo({
templateOverride: `<div string-model model="{{tit¦le}}"></div>`, templateOverride: `<div string-model model="{{tit¦le}}"></div>`,
expectedSpanText: 'title', expectedSpanText: 'title',
expectedDisplayString: '(property) AppComponent.title: string' expectedDisplayString: '(property) AppCmp.title: string'
}); });
}); });
@ -296,7 +378,7 @@ describe('quick info', () => {
expectQuickInfo({ expectQuickInfo({
templateOverride: `<test-comp [tcName]="ti¦tle"></test-comp>`, templateOverride: `<test-comp [tcName]="ti¦tle"></test-comp>`,
expectedSpanText: 'title', expectedSpanText: 'title',
expectedDisplayString: '(property) AppComponent.title: string' expectedDisplayString: '(property) AppCmp.title: string'
}); });
}); });
@ -312,7 +394,7 @@ describe('quick info', () => {
expectQuickInfo({ expectQuickInfo({
templateOverride: `<test-comp (test)="ti¦tle=$event"></test-comp>`, templateOverride: `<test-comp (test)="ti¦tle=$event"></test-comp>`,
expectedSpanText: 'title', expectedSpanText: 'title',
expectedDisplayString: '(property) AppComponent.title: string' expectedDisplayString: '(property) AppCmp.title: string'
}); });
}); });
@ -320,7 +402,7 @@ describe('quick info', () => {
expectQuickInfo({ expectQuickInfo({
templateOverride: `<div (click)="setT¦itle('title')"></div>`, templateOverride: `<div (click)="setT¦itle('title')"></div>`,
expectedSpanText: 'setTitle', expectedSpanText: 'setTitle',
expectedDisplayString: '(method) AppComponent.setTitle(newTitle: string): void' expectedDisplayString: '(method) AppCmp.setTitle(newTitle: string): void'
}); });
}); });
@ -342,9 +424,9 @@ describe('quick info', () => {
it('should find members of two-way binding', () => { it('should find members of two-way binding', () => {
expectQuickInfo({ expectQuickInfo({
templateOverride: `<input [(ngModel)]="ti¦tle" />`, templateOverride: `<input string-model [(model)]="ti¦tle" />`,
expectedSpanText: 'title', expectedSpanText: 'title',
expectedDisplayString: '(property) AppComponent.title: string' expectedDisplayString: '(property) AppCmp.title: string'
}); });
}); });
@ -352,15 +434,15 @@ describe('quick info', () => {
expectQuickInfo({ expectQuickInfo({
templateOverride: `<div *ngIf="anyV¦alue"></div>`, templateOverride: `<div *ngIf="anyV¦alue"></div>`,
expectedSpanText: 'anyValue', expectedSpanText: 'anyValue',
expectedDisplayString: '(property) AppComponent.anyValue: any' expectedDisplayString: '(property) AppCmp.anyValue: any'
}); });
}); });
it('should work for members in structural directives', () => { it('should work for members in structural directives', () => {
expectQuickInfo({ expectQuickInfo({
templateOverride: `<div *ngFor="let item of her¦oes; trackBy: test;"></div>`, templateOverride: `<div *ngFor="let item of her¦oes; trackBy: trackByFn;"></div>`,
expectedSpanText: 'heroes', expectedSpanText: 'heroes',
expectedDisplayString: '(property) AppComponent.heroes: Hero[]' expectedDisplayString: '(property) AppCmp.heroes: Hero[]'
}); });
}); });
@ -373,20 +455,11 @@ describe('quick info', () => {
}); });
it('should provide documentation', () => { it('should provide documentation', () => {
const {position} = service.overwriteInlineTemplate(APP_COMPONENT, `<div>{{¦title}}</div>`); const {cursor} = env.overrideTemplateWithCursor(
const quickInfo = ngLS.getQuickInfoAtPosition(APP_COMPONENT, position); absoluteFrom('/app.ts'), 'AppCmp', `<div>{{¦title}}</div>`);
const quickInfo = env.ngLS.getQuickInfoAtPosition(absoluteFrom('/app.html'), cursor);
const documentation = toText(quickInfo!.documentation); const documentation = toText(quickInfo!.documentation);
expect(documentation).toBe('This is the title of the `AppComponent` Component.'); expect(documentation).toBe('This is the title of the `AppCmp` Component.');
});
it('works with external template', () => {
const {position, text} = service.overwrite(TEST_TEMPLATE, '<butt¦on></button>');
const quickInfo = ngLS.getQuickInfoAtPosition(TEST_TEMPLATE, position);
expect(quickInfo).toBeTruthy();
const {textSpan, displayParts} = quickInfo!;
expect(text.substring(textSpan.start, textSpan.start + textSpan.length))
.toEqual('<button></button>');
expect(toText(displayParts)).toEqual('(element) button: HTMLButtonElement');
}); });
}); });
@ -394,8 +467,11 @@ describe('quick info', () => {
{templateOverride, expectedSpanText, expectedDisplayString}: {templateOverride, expectedSpanText, expectedDisplayString}:
{templateOverride: string, expectedSpanText: string, expectedDisplayString: string}): {templateOverride: string, expectedSpanText: string, expectedDisplayString: string}):
ts.QuickInfo { ts.QuickInfo {
const {position, text} = service.overwriteInlineTemplate(APP_COMPONENT, templateOverride); const {cursor, text} =
const quickInfo = ngLS.getQuickInfoAtPosition(APP_COMPONENT, position); env.overrideTemplateWithCursor(absoluteFrom('/app.ts'), 'AppCmp', templateOverride);
env.expectNoSourceDiagnostics();
env.expectNoTemplateDiagnostics(absoluteFrom('/app.ts'), 'AppCmp');
const quickInfo = env.ngLS.getQuickInfoAtPosition(absoluteFrom('/app.html'), cursor);
expect(quickInfo).toBeTruthy(); expect(quickInfo).toBeTruthy();
const {textSpan, displayParts} = quickInfo!; const {textSpan, displayParts} = quickInfo!;
expect(text.substring(textSpan.start, textSpan.start + textSpan.length)) expect(text.substring(textSpan.start, textSpan.start + textSpan.length))