test(language-service): introduce new, more configurable testing env (#40679)

The Ivy Language Service codebase testing suite contains a few testing
utilities which allow for assertions of Language Service operations against
an in-memory project. However, this existing utility lacks the flexibility
to test more complex scenarios, such as those involving multiple TS projects
with dependencies between them.

This commit introduces a new 'testing' package for the Ivy LS which attempts
to more faithfully represent the possible states of an IDE, and allows for
testing of more advanced scenarios. The new utility borrows from the prior
version and is geared towards more ergonomic testing. Only basic
functionality is present in this initial implementation, but this will grow
over time.

PR Close #40679
This commit is contained in:
Alex Rickabaugh 2021-02-02 14:11:52 -08:00 committed by atscott
parent d1d1dadb41
commit c9879deded
8 changed files with 475 additions and 0 deletions

View File

@ -18,6 +18,8 @@ import {LanguageService} from '../language_service';
import {MockServerHost} from './mock_host';
// TODO(alxhub): replace this environment with //packages/language-service/ivy/testing
function writeTsconfig(
fs: FileSystem, entryFiles: AbsoluteFsPath[], options: TestableOptions): void {
fs.writeFile(

View File

@ -0,0 +1,19 @@
load("//tools:defaults.bzl", "ts_library")
package(default_visibility = ["//packages/language-service:__subpackages__"])
ts_library(
name = "testing",
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",
],
)

View File

@ -0,0 +1,12 @@
/**
* @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
*/
export * from './src/buffer';
export * from './src/env';
export * from './src/project';
export * from './src/util';

View File

@ -0,0 +1,64 @@
/**
* @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 * as ts from 'typescript/lib/tsserverlibrary';
import {Project} from './project';
import {extractCursorInfo} from './util';
/**
* A file that is currently open in the `ts.Project`, with a cursor position.
*/
export class OpenBuffer {
private _cursor: number = 0;
constructor(
private project: Project, private projectFileName: string,
private scriptInfo: ts.server.ScriptInfo) {}
get cursor(): number {
return this._cursor;
}
get contents(): string {
const snapshot = this.scriptInfo.getSnapshot();
return snapshot.getText(0, snapshot.getLength());
}
set contents(newContents: string) {
const snapshot = this.scriptInfo.getSnapshot();
this.scriptInfo.editContent(0, snapshot.getLength(), newContents);
// If the cursor goes beyond the new length of the buffer, clamp it to the end of the buffer.
if (this._cursor > newContents.length) {
this._cursor = newContents.length;
}
}
/**
* Find a snippet of text within the given buffer and position the cursor within it.
*
* @param snippetWithCursor a snippet of text which contains the '¦' symbol, representing where
* the cursor should be placed within the snippet when located in the larger buffer.
*/
moveCursorToText(snippetWithCursor: string): void {
const {text: snippet, cursor} = extractCursorInfo(snippetWithCursor);
const snippetIndex = this.contents.indexOf(snippet);
if (snippetIndex === -1) {
throw new Error(`Snippet ${snippet} not found in ${this.projectFileName}`);
}
this._cursor = snippetIndex + cursor;
}
/**
* Execute the `getDefinitionAndBoundSpan` operation in the Language Service at the cursor
* location in this buffer.
*/
getDefinitionAndBoundSpan(): ts.DefinitionInfoAndBoundSpan|undefined {
return this.project.ngLS.getDefinitionAndBoundSpan(this.scriptInfo.fileName, this._cursor);
}
}

View File

@ -0,0 +1,87 @@
/**
* @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 {getFileSystem} from '@angular/compiler-cli/src/ngtsc/file_system';
import {MockFileSystem} from '@angular/compiler-cli/src/ngtsc/file_system/testing';
import {loadStandardTestFiles} from '@angular/compiler-cli/src/ngtsc/testing';
import * as ts from 'typescript/lib/tsserverlibrary';
import {MockServerHost} from './host';
import {Project, ProjectFiles} from './project';
/**
* Testing environment for the Angular Language Service, which creates an in-memory tsserver
* instance that backs a Language Service to emulate an IDE that uses the LS.
*/
export class LanguageServiceTestEnv {
static setup(): LanguageServiceTestEnv {
const fs = getFileSystem();
if (!(fs instanceof MockFileSystem)) {
throw new Error(`LanguageServiceTestEnvironment only works with a mock filesystem`);
}
fs.init(loadStandardTestFiles({
fakeCore: true,
fakeCommon: true,
}));
const host = new MockServerHost(fs);
const projectService = new ts.server.ProjectService({
logger,
cancellationToken: ts.server.nullCancellationToken,
host,
typingsInstaller: ts.server.nullTypingsInstaller,
useInferredProjectPerProjectRoot: true,
useSingleInferredProject: true,
});
return new LanguageServiceTestEnv(host, projectService);
}
private projects = new Map<string, Project>();
constructor(private host: MockServerHost, private projectService: ts.server.ProjectService) {}
addProject(name: string, files: ProjectFiles): Project {
if (this.projects.has(name)) {
throw new Error(`Project ${name} is already defined`);
}
const project = Project.initialize(name, this.projectService, files);
this.projects.set(name, project);
return project;
}
getTextFromTsSpan(fileName: string, span: ts.TextSpan): string|null {
const scriptInfo = this.projectService.getScriptInfo(fileName);
if (scriptInfo === undefined) {
return null;
}
return scriptInfo.getSnapshot().getText(span.start, span.start + span.length);
}
}
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;
},
};

View File

@ -0,0 +1,118 @@
/**
* @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() {}
};
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

@ -0,0 +1,122 @@
/**
* @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 {StrictTemplateOptions} from '@angular/compiler-cli/src/ngtsc/core/api';
import {absoluteFrom, AbsoluteFsPath, FileSystem, getFileSystem} from '@angular/compiler-cli/src/ngtsc/file_system';
import * as ts from 'typescript/lib/tsserverlibrary';
import {LanguageService} from '../../language_service';
import {OpenBuffer} from './buffer';
export type ProjectFiles = {
[fileName: string]: string;
};
function writeTsconfig(
fs: FileSystem, tsConfigPath: AbsoluteFsPath, entryFiles: AbsoluteFsPath[],
options: StrictTemplateOptions): void {
fs.writeFile(
tsConfigPath,
JSON.stringify(
{
compilerOptions: {
strict: true,
experimentalDecorators: true,
moduleResolution: 'node',
target: 'es2015',
rootDir: '.',
lib: [
'dom',
'es2015',
],
},
files: entryFiles,
angularCompilerOptions: {
strictTemplates: true,
...options,
}
},
null, 2));
}
export class Project {
private tsProject: ts.server.Project;
private tsLS: ts.LanguageService;
readonly ngLS: LanguageService;
private buffers = new Map<string, OpenBuffer>();
static initialize(name: string, projectService: ts.server.ProjectService, files: ProjectFiles):
Project {
const fs = getFileSystem();
const tsConfigPath = absoluteFrom(`/${name}/tsconfig.json`);
const entryFiles: AbsoluteFsPath[] = [];
for (const projectFilePath of Object.keys(files)) {
const contents = files[projectFilePath];
const filePath = absoluteFrom(`/${name}/${projectFilePath}`);
const dirPath = fs.dirname(filePath);
fs.ensureDir(dirPath);
fs.writeFile(filePath, contents);
if (projectFilePath.endsWith('.ts')) {
entryFiles.push(filePath);
}
}
writeTsconfig(fs, tsConfigPath, entryFiles, {});
// Ensure the project is live in the ProjectService.
projectService.openClientFile(entryFiles[0]);
projectService.closeClientFile(entryFiles[0]);
return new Project(name, projectService, tsConfigPath);
}
constructor(
readonly name: string, private projectService: ts.server.ProjectService,
private tsConfigPath: AbsoluteFsPath) {
// LS for project
const tsProject = projectService.findProject(tsConfigPath);
if (tsProject === undefined) {
throw new Error(`Failed to create project for ${tsConfigPath}`);
}
this.tsProject = tsProject;
// The following operation forces a ts.Program to be created.
this.tsLS = tsProject.getLanguageService();
this.ngLS = new LanguageService(tsProject, this.tsLS);
}
openFile(projectFileName: string): OpenBuffer {
if (!this.buffers.has(projectFileName)) {
const fileName = absoluteFrom(`/${this.name}/${projectFileName}`);
this.projectService.openClientFile(fileName);
const scriptInfo = this.tsProject.getScriptInfo(fileName);
if (scriptInfo === undefined) {
throw new Error(
`Unable to open ScriptInfo for ${projectFileName} in project ${this.tsConfigPath}`);
}
this.buffers.set(projectFileName, new OpenBuffer(this, projectFileName, scriptInfo));
}
return this.buffers.get(projectFileName)!;
}
getDiagnosticsForFile(projectFileName: string): ts.Diagnostic[] {
const fileName = absoluteFrom(`/${this.name}/${projectFileName}`);
const diagnostics: ts.Diagnostic[] = [];
if (fileName.endsWith('.ts')) {
diagnostics.push(...this.tsLS.getSyntacticDiagnostics(fileName));
diagnostics.push(...this.tsLS.getSemanticDiagnostics(fileName));
}
diagnostics.push(...this.ngLS.getSemanticDiagnostics(fileName));
return diagnostics;
}
}

View File

@ -0,0 +1,51 @@
/**
* @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
*/
/**
* Given a text snippet which contains exactly one cursor symbol ('¦'), extract both the offset of
* that cursor within the text as well as the text snippet without the cursor.
*/
export function extractCursorInfo(textWithCursor: string): {cursor: number, text: string} {
const cursor = textWithCursor.indexOf('¦');
if (cursor === -1 || textWithCursor.indexOf('¦', cursor + 1) !== -1) {
throw new Error(`Expected to find exactly one cursor symbol '¦'`);
}
return {
cursor,
text: textWithCursor.substr(0, cursor) + textWithCursor.substr(cursor + 1),
};
}
function last<T>(array: T[]): T {
return array[array.length - 1];
}
/**
* Expect that a list of objects with a `fileName` property matches a set of expected files by only
* comparing the file names and not any path prefixes.
*
* This assertion is independent of the order of either list.
*/
export function assertFileNames(refs: Array<{fileName: string}>, expectedFileNames: string[]) {
const actualPaths = refs.map(r => r.fileName);
const actualFileNames = actualPaths.map(p => last(p.split('/')));
expect(new Set(actualFileNames)).toEqual(new Set(expectedFileNames));
}
/**
* Returns whether the given `ts.Diagnostic` is of a type only produced by the Angular compiler (as
* opposed to being an upstream TypeScript diagnostic).
*
* Template type-checking diagnostics are not "ng-specific" in this sense, since they are plain
* TypeScript diagnostics that are produced from expressions in the template by way of a TCB.
*/
export function isNgSpecificDiagnostic(diag: ts.Diagnostic): boolean {
// Angular-specific diagnostics use a negative code space.
return diag.code < 0;
}