From da79e0433f14033dfa4573c5261c9e6a8b9bef83 Mon Sep 17 00:00:00 2001 From: Keen Yee Liau Date: Wed, 29 Apr 2020 15:52:17 -0700 Subject: [PATCH] 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 --- .../language-service/ivy/test/mock_host.ts | 85 +++++++++++++++++-- .../ivy/test/mock_host_spec.ts | 31 ++++++- 2 files changed, 109 insertions(+), 7 deletions(-) diff --git a/packages/language-service/ivy/test/mock_host.ts b/packages/language-service/ivy/test/mock_host.ts index f9a39ff3c4..3c322eda21 100644 --- a/packages/language-service/ivy/test/mock_host.ts +++ b/packages/language-service/ivy/test/mock_host.ts @@ -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(); + + 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, ''); } diff --git a/packages/language-service/ivy/test/mock_host_spec.ts b/packages/language-service/ivy/test/mock_host_spec.ts index 848b0db319..5a2d685f82 100644 --- a/packages/language-service/ivy/test/mock_host_spec.ts +++ b/packages/language-service/ivy/test/mock_host_spec.ts @@ -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()); +}