test(language-service): [ivy] Add mock service to overwrite files (#36923)

Add a mechanism to replace file contents for a specific file. This
allows us to write custom test scenarios in code without modifying the
test project.

Since we are no longer mocking the language service host, the file
overwrite needs to happen via the project service.
Project service manages a set of script infos, and overwriting the files
is a matter of updating the relevant script infos.

Note that the actual project service is wrapped inside a Mock Service.
Tests should not have direct access to the project service. All
manipulations should take place via the Mock Service.

The MockService provides a `reset()` method to undo temporary overwrites
after each test.

PR Close #36923
This commit is contained in:
Keen Yee Liau 2020-04-29 15:52:17 -07:00 committed by Alex Rickabaugh
parent 1b8752e595
commit da79e0433f
2 changed files with 109 additions and 7 deletions

View File

@ -36,6 +36,10 @@ export const APP_COMPONENT = join(PROJECT_DIR, 'app', 'app.component.ts');
export const APP_MAIN = join(PROJECT_DIR, 'app', 'main.ts');
export const PARSING_CASES = join(PROJECT_DIR, 'app', 'parsing-cases.ts');
const NOOP_FILE_WATCHER: ts.FileWatcher = {
close() {}
};
export const host: ts.server.ServerHost = {
...ts.sys,
readFile(absPath: string, encoding?: string): string |
@ -44,10 +48,25 @@ export const host: ts.server.ServerHost = {
// MockTypescriptHost
return ts.sys.readFile(absPath, encoding);
},
// TODO: Need to cast as never because this is not a proper ServerHost interface.
// ts.sys lacks methods like watchFile() and watchDirectory(), but these are not
// needed for now.
} as never;
watchFile(path: string, callback: ts.FileWatcherCallback): ts.FileWatcher {
return NOOP_FILE_WATCHER;
},
watchDirectory(path: string, callback: ts.DirectoryWatcherCallback): ts.FileWatcher {
return NOOP_FILE_WATCHER;
},
setTimeout() {
throw new Error('setTimeout is not implemented');
},
clearTimeout() {
throw new Error('clearTimeout is not implemented');
},
setImmediate() {
throw new Error('setImmediate is not implemented');
},
clearImmediate() {
throw new Error('clearImmediate is not implemented');
},
};
/**
* Create a ConfiguredProject and an actual program for the test project located
@ -73,5 +92,61 @@ export function setup() {
}
// The following operation forces a ts.Program to be created.
const tsLS = project.getLanguageService();
return {projectService, project, tsLS};
return {
service: new MockService(project, projectService),
project,
tsLS,
};
}
class MockService {
private readonly overwritten = new Set<ts.server.NormalizedPath>();
constructor(
private readonly project: ts.server.Project,
private readonly ps: ts.server.ProjectService,
) {}
overwrite(fileName: string, newText: string): string {
const scriptInfo = this.getScriptInfo(fileName);
this.overwritten.add(scriptInfo.fileName);
const snapshot = scriptInfo.getSnapshot();
scriptInfo.editContent(0, snapshot.getLength(), preprocess(newText));
const sameProgram = this.project.updateGraph(); // clear the dirty flag
if (sameProgram) {
throw new Error('Project should have updated program after overwrite');
}
return newText;
}
reset() {
if (this.overwritten.size === 0) {
return;
}
for (const fileName of this.overwritten) {
const scriptInfo = this.getScriptInfo(fileName);
const reloaded = scriptInfo.reloadFromFile();
if (!reloaded) {
throw new Error(`Failed to reload ${scriptInfo.fileName}`);
}
}
const sameProgram = this.project.updateGraph();
if (sameProgram) {
throw new Error('Project should have updated program after reset');
}
this.overwritten.clear();
}
getScriptInfo(fileName: string): ts.server.ScriptInfo {
const scriptInfo = this.ps.getScriptInfo(fileName);
if (!scriptInfo) {
throw new Error(`No existing script info for ${fileName}`);
}
return scriptInfo;
}
}
const REGEX_CURSOR = /¦/g;
function preprocess(text: string): string {
return text.replace(REGEX_CURSOR, '');
}

View File

@ -11,9 +11,17 @@ import * as ts from 'typescript/lib/tsserverlibrary';
import {APP_MAIN, setup, TEST_SRCDIR} from './mock_host';
describe('mock host', () => {
const {project, service, tsLS} = setup();
beforeEach(() => {
service.reset();
});
it('can load test project from Bazel runfiles', () => {
const {project, tsLS} = setup();
expect(project).toBeInstanceOf(ts.server.ConfiguredProject);
const configPath = (project as ts.server.ConfiguredProject).getConfigFilePath();
expect(configPath.substring(TEST_SRCDIR.length))
.toBe('/angular/packages/language-service/test/project/tsconfig.json');
const program = tsLS.getProgram();
expect(program).toBeDefined();
const sourceFiles = program!.getSourceFiles().map(sf => {
@ -36,7 +44,6 @@ describe('mock host', () => {
});
it('produces no TS error for test project', () => {
const {project, tsLS} = setup();
const errors = project.getAllProjectErrors();
expect(errors).toEqual([]);
const globalErrors = project.getGlobalProjectErrors();
@ -44,4 +51,24 @@ describe('mock host', () => {
const diags = tsLS.getSemanticDiagnostics(APP_MAIN);
expect(diags).toEqual([]);
});
it('can overwrite test file', () => {
service.overwrite(APP_MAIN, `const x: string = 0`);
const scriptInfo = service.getScriptInfo(APP_MAIN);
expect(getText(scriptInfo)).toBe('const x: string = 0');
});
it('can find the cursor', () => {
const content = service.overwrite(APP_MAIN, `const fo¦o = 'hello world';`);
// content returned by overwrite() is the original content with cursor
expect(content).toBe(`const fo¦o = 'hello world';`);
const scriptInfo = service.getScriptInfo(APP_MAIN);
// script info content should not contain cursor
expect(getText(scriptInfo)).toBe(`const foo = 'hello world';`);
});
});
function getText(scriptInfo: ts.server.ScriptInfo): string {
const snapshot = scriptInfo.getSnapshot();
return snapshot.getText(0, snapshot.getLength());
}