From 69e5abd5b774cf34d9204c1e56faaa5b2f228ed3 Mon Sep 17 00:00:00 2001 From: Sheetal Nandi Date: Wed, 26 Jul 2017 11:57:10 -0700 Subject: [PATCH] Refactor watched system from tsserver tests so that tscLib watch can leverage it --- src/harness/harness.ts | 4 + src/harness/harnessLanguageService.ts | 4 - src/harness/tsconfig.json | 3 +- .../unittests/cachingInServerLSHost.ts | 2 +- src/harness/unittests/session.ts | 2 +- src/harness/unittests/telemetry.ts | 8 +- .../unittests/tsserverProjectSystem.ts | 502 +----------------- src/harness/virtualFileSystemWithWatch.ts | 492 +++++++++++++++++ 8 files changed, 517 insertions(+), 500 deletions(-) create mode 100644 src/harness/virtualFileSystemWithWatch.ts diff --git a/src/harness/harness.ts b/src/harness/harness.ts index 8f4dae7011..c0181199cb 100644 --- a/src/harness/harness.ts +++ b/src/harness/harness.ts @@ -753,6 +753,10 @@ namespace Harness { } } + export function mockHash(s: string): string { + return `hash-${s}`; + } + const environment = Utils.getExecutionEnvironment(); switch (environment) { case Utils.ExecutionEnvironment.Node: diff --git a/src/harness/harnessLanguageService.ts b/src/harness/harnessLanguageService.ts index c604b22465..22878f5a89 100644 --- a/src/harness/harnessLanguageService.ts +++ b/src/harness/harnessLanguageService.ts @@ -853,8 +853,4 @@ namespace Harness.LanguageService { getClassifier(): ts.Classifier { throw new Error("getClassifier is not available using the server interface."); } getPreProcessedFileInfo(): ts.PreProcessedFileInfo { throw new Error("getPreProcessedFileInfo is not available using the server interface."); } } - - export function mockHash(s: string): string { - return `hash-${s}`; - } } diff --git a/src/harness/tsconfig.json b/src/harness/tsconfig.json index 66ca2fc3f4..dfdd0a2fee 100644 --- a/src/harness/tsconfig.json +++ b/src/harness/tsconfig.json @@ -1,4 +1,4 @@ -{ +{ "extends": "../tsconfig-base", "compilerOptions": { "removeComments": false, @@ -44,6 +44,7 @@ "../compiler/declarationEmitter.ts", "../compiler/emitter.ts", "../compiler/program.ts", + "../compiler/builder.ts", "../compiler/commandLineParser.ts", "../compiler/diagnosticInformationMap.generated.ts", "../services/breakpoints.ts", diff --git a/src/harness/unittests/cachingInServerLSHost.ts b/src/harness/unittests/cachingInServerLSHost.ts index 5265a12355..12efa5717a 100644 --- a/src/harness/unittests/cachingInServerLSHost.ts +++ b/src/harness/unittests/cachingInServerLSHost.ts @@ -47,7 +47,7 @@ namespace ts { clearTimeout, setImmediate: typeof setImmediate !== "undefined" ? setImmediate : action => setTimeout(action, 0), clearImmediate: typeof clearImmediate !== "undefined" ? clearImmediate : clearTimeout, - createHash: Harness.LanguageService.mockHash, + createHash: Harness.mockHash, }; } diff --git a/src/harness/unittests/session.ts b/src/harness/unittests/session.ts index 862ebee4b0..31de635609 100644 --- a/src/harness/unittests/session.ts +++ b/src/harness/unittests/session.ts @@ -25,7 +25,7 @@ namespace ts.server { clearTimeout: noop, setImmediate: () => 0, clearImmediate: noop, - createHash: Harness.LanguageService.mockHash, + createHash: Harness.mockHash, }; const mockLogger: Logger = { diff --git a/src/harness/unittests/telemetry.ts b/src/harness/unittests/telemetry.ts index a856250307..e7a64c5990 100644 --- a/src/harness/unittests/telemetry.ts +++ b/src/harness/unittests/telemetry.ts @@ -53,7 +53,7 @@ namespace ts.projectSystem { // TODO: Apparently compilerOptions is mutated, so have to repeat it here! et.assertProjectInfoTelemetryEvent({ - projectId: Harness.LanguageService.mockHash("/hunter2/foo.csproj"), + projectId: Harness.mockHash("/hunter2/foo.csproj"), compilerOptions: { strict: true }, compileOnSave: true, // These properties can't be present for an external project, so they are undefined instead of false. @@ -195,7 +195,7 @@ namespace ts.projectSystem { const et = new EventTracker([jsconfig, file]); et.service.openClientFile(file.path); et.assertProjectInfoTelemetryEvent({ - projectId: Harness.LanguageService.mockHash("/jsconfig.json"), + projectId: Harness.mockHash("/jsconfig.json"), fileStats: fileStats({ js: 1 }), compilerOptions: autoJsCompilerOptions, typeAcquisition: { @@ -215,7 +215,7 @@ namespace ts.projectSystem { et.service.openClientFile(file.path); et.getEvent(server.ProjectLanguageServiceStateEvent, /*mayBeMore*/ true); et.assertProjectInfoTelemetryEvent({ - projectId: Harness.LanguageService.mockHash("/jsconfig.json"), + projectId: Harness.mockHash("/jsconfig.json"), fileStats: fileStats({ js: 1 }), compilerOptions: autoJsCompilerOptions, configFileName: "jsconfig.json", @@ -251,7 +251,7 @@ namespace ts.projectSystem { assertProjectInfoTelemetryEvent(partial: Partial): void { assert.deepEqual(this.getEvent(ts.server.ProjectInfoTelemetryEvent), { - projectId: Harness.LanguageService.mockHash("/tsconfig.json"), + projectId: Harness.mockHash("/tsconfig.json"), fileStats: fileStats({ ts: 1 }), compilerOptions: {}, extends: false, diff --git a/src/harness/unittests/tsserverProjectSystem.ts b/src/harness/unittests/tsserverProjectSystem.ts index ca86138803..b7c71f2819 100644 --- a/src/harness/unittests/tsserverProjectSystem.ts +++ b/src/harness/unittests/tsserverProjectSystem.ts @@ -1,4 +1,5 @@ /// +/// /// namespace ts.projectSystem { @@ -6,17 +7,18 @@ namespace ts.projectSystem { import protocol = server.protocol; import CommandNames = server.CommandNames; - const safeList = { - path: "/safeList.json", - content: JSON.stringify({ - commander: "commander", - express: "express", - jquery: "jquery", - lodash: "lodash", - moment: "moment", - chroma: "chroma-js" - }) - }; + export import TestServerHost = ts.TestFSWithWatch.TestServerHost; + export type TestServerHostCreationParameters = ts.TestFSWithWatch.TestServerHostCreationParameters; + export type File = ts.TestFSWithWatch.File; + export type FileOrFolder = ts.TestFSWithWatch.FileOrFolder; + export type Folder = ts.TestFSWithWatch.Folder; + export type FSEntry = ts.TestFSWithWatch.FSEntry; + export import createServerHost = ts.TestFSWithWatch.createServerHost; + export import checkFileNames = ts.TestFSWithWatch.checkFileNames; + export import libFile = ts.TestFSWithWatch.libFile; + export import checkWatchedFiles = ts.TestFSWithWatch.checkWatchedFiles; + export import checkWatchedDirectories = ts.TestFSWithWatch.checkWatchedDirectories; + import safeList = ts.TestFSWithWatch.safeList; const customSafeList = { path: "/typeMapList.json", @@ -45,12 +47,6 @@ namespace ts.projectSystem { getLogFileName: (): string => undefined }; - export const { content: libFileContent } = Harness.getDefaultLibraryFile(Harness.IO); - export const libFile: FileOrFolder = { - path: "/a/lib/lib.d.ts", - content: libFileContent - }; - export class TestTypingsInstaller extends TI.TypingsInstaller implements server.ITypingsInstaller { protected projectService: server.ProjectService; constructor( @@ -118,10 +114,6 @@ namespace ts.projectSystem { return JSON.stringify({ dependencies }); } - export function getExecutingFilePathFromLibFile(): string { - return combinePaths(getDirectoryPath(libFile.path), "tsc.js"); - } - export function toExternalFile(fileName: string): protocol.ExternalFile { return { fileName }; } @@ -143,26 +135,6 @@ namespace ts.projectSystem { } } - export interface TestServerHostCreationParameters { - useCaseSensitiveFileNames?: boolean; - executingFilePath?: string; - currentDirectory?: string; - newLine?: string; - } - - export function createServerHost(fileOrFolderList: FileOrFolder[], params?: TestServerHostCreationParameters): TestServerHost { - if (!params) { - params = {}; - } - const host = new TestServerHost( - params.useCaseSensitiveFileNames !== undefined ? params.useCaseSensitiveFileNames : false, - params.executingFilePath || getExecutingFilePathFromLibFile(), - params.currentDirectory || "/", - fileOrFolderList, - params.newLine); - return host; - } - class TestSession extends server.Session { private seq = 0; @@ -213,7 +185,6 @@ namespace ts.projectSystem { eventHandler?: server.ProjectServiceEventHandler; } - export class TestProjectService extends server.ProjectService { constructor(host: server.ServerHost, logger: server.Logger, cancellationToken: HostCancellationToken, useSingleInferredProject: boolean, typingsInstaller: server.ITypingsInstaller, eventHandler: server.ProjectServiceEventHandler) { @@ -233,67 +204,6 @@ namespace ts.projectSystem { return new TestProjectService(host, logger, cancellationToken, useSingleInferredProject, parameters.typingsInstaller, parameters.eventHandler); } - export interface FileOrFolder { - path: string; - content?: string; - fileSize?: number; - } - - export interface FSEntry { - path: Path; - fullPath: string; - } - - export interface File extends FSEntry { - content: string; - fileSize?: number; - } - - export interface Folder extends FSEntry { - entries: FSEntry[]; - } - - export function isFolder(s: FSEntry): s is Folder { - return s && isArray((s).entries); - } - - export function isFile(s: FSEntry): s is File { - return s && isString((s).content); - } - - function invokeDirectoryWatcher(callbacks: DirectoryWatcherCallback[], getRelativeFilePath: () => string) { - if (callbacks) { - const cbs = callbacks.slice(); - for (const cb of cbs) { - const fileName = getRelativeFilePath(); - cb(fileName); - } - } - } - - function invokeFileWatcher(callbacks: FileWatcherCallback[], fileName: string, eventId: FileWatcherEventKind) { - if (callbacks) { - const cbs = callbacks.slice(); - for (const cb of cbs) { - cb(fileName, eventId); - } - } - } - - export function checkMapKeys(caption: string, map: Map, expectedKeys: string[]) { - assert.equal(map.size, expectedKeys.length, `${caption}: incorrect size of map: Actual keys: ${arrayFrom(map.keys())} Expected: ${expectedKeys}`); - for (const name of expectedKeys) { - assert.isTrue(map.has(name), `${caption} is expected to contain ${name}, actual keys: ${arrayFrom(map.keys())}`); - } - } - - export function checkFileNames(caption: string, actualFileNames: string[], expectedFileNames: string[]) { - assert.equal(actualFileNames.length, expectedFileNames.length, `${caption}: incorrect actual number of files, expected ${JSON.stringify(expectedFileNames)}, got ${actualFileNames}`); - for (const f of expectedFileNames) { - assert.isTrue(contains(actualFileNames, f), `${caption}: expected to find ${f} in ${JSON.stringify(actualFileNames)}`); - } - } - export function checkNumberOfConfiguredProjects(projectService: server.ProjectService, expected: number) { assert.equal(projectService.configuredProjects.size, expected, `expected ${expected} configured project(s)`); } @@ -321,14 +231,6 @@ namespace ts.projectSystem { return values.next().value; } - export function checkWatchedFiles(host: TestServerHost, expectedFiles: string[]) { - checkMapKeys("watchedFiles", host.watchedFiles, expectedFiles); - } - - export function checkWatchedDirectories(host: TestServerHost, expectedDirectories: string[], recursive = false) { - checkMapKeys("watchedDirectories", recursive ? host.watchedDirectoriesRecursive : host.watchedDirectories, expectedDirectories); - } - export function checkProjectActualFiles(project: server.Project, expectedFiles: string[]) { checkFileNames(`${server.ProjectKind[project.projectKind]} project, actual files`, project.getFileNames(), expectedFiles); } @@ -337,384 +239,6 @@ namespace ts.projectSystem { checkFileNames(`${server.ProjectKind[project.projectKind]} project, rootFileNames`, project.getRootFiles(), expectedFiles); } - export class Callbacks { - private map: TimeOutCallback[] = []; - private nextId = 1; - - register(cb: (...args: any[]) => void, args: any[]) { - const timeoutId = this.nextId; - this.nextId++; - this.map[timeoutId] = cb.bind(/*this*/ undefined, ...args); - return timeoutId; - } - - unregister(id: any) { - if (typeof id === "number") { - delete this.map[id]; - } - } - - count() { - let n = 0; - for (const _ in this.map) { - n++; - } - return n; - } - - invoke() { - // Note: invoking a callback may result in new callbacks been queued, - // so do not clear the entire callback list regardless. Only remove the - // ones we have invoked. - for (const key in this.map) { - this.map[key](); - delete this.map[key]; - } - } - } - - export type TimeOutCallback = () => any; - - export class TestServerHost implements server.ServerHost { - args: string[] = []; - - private readonly output: string[] = []; - - private fs: Map = createMap(); - private getCanonicalFileName: (s: string) => string; - private toPath: (f: string) => Path; - private timeoutCallbacks = new Callbacks(); - private immediateCallbacks = new Callbacks(); - - readonly watchedDirectories = createMultiMap(); - readonly watchedDirectoriesRecursive = createMultiMap(); - readonly watchedFiles = createMultiMap(); - - constructor(public useCaseSensitiveFileNames: boolean, private executingFilePath: string, private currentDirectory: string, fileOrFolderList: FileOrFolder[], public readonly newLine = "\n") { - this.getCanonicalFileName = createGetCanonicalFileName(useCaseSensitiveFileNames); - this.toPath = s => toPath(s, currentDirectory, this.getCanonicalFileName); - - this.reloadFS(fileOrFolderList); - } - - private toFullPath(s: string) { - const fullPath = getNormalizedAbsolutePath(s, this.currentDirectory); - return this.toPath(fullPath); - } - - reloadFS(fileOrFolderList: FileOrFolder[]) { - const mapNewLeaves = createMap(); - const isNewFs = this.fs.size === 0; - // always inject safelist file in the list of files - for (const fileOrFolder of fileOrFolderList.concat(safeList)) { - const path = this.toFullPath(fileOrFolder.path); - mapNewLeaves.set(path, true); - // If its a change - const currentEntry = this.fs.get(path); - if (currentEntry) { - if (isFile(currentEntry)) { - if (isString(fileOrFolder.content)) { - // Update file - if (currentEntry.content !== fileOrFolder.content) { - currentEntry.content = fileOrFolder.content; - this.invokeFileWatcher(currentEntry.fullPath, FileWatcherEventKind.Changed); - } - } - else { - // TODO: Changing from file => folder - } - } - else { - // Folder - if (isString(fileOrFolder.content)) { - // TODO: Changing from folder => file - } - else { - // Folder update: Nothing to do. - } - } - } - else { - this.ensureFileOrFolder(fileOrFolder); - } - } - - if (!isNewFs) { - this.fs.forEach((fileOrFolder, path) => { - // If this entry is not from the new file or folder - if (!mapNewLeaves.get(path)) { - // Leaf entries that arent in new list => remove these - if (isFile(fileOrFolder) || isFolder(fileOrFolder) && fileOrFolder.entries.length === 0) { - this.removeFileOrFolder(fileOrFolder, folder => !mapNewLeaves.get(folder.path)); - } - } - }); - } - } - - ensureFileOrFolder(fileOrFolder: FileOrFolder) { - if (isString(fileOrFolder.content)) { - const file = this.toFile(fileOrFolder); - Debug.assert(!this.fs.get(file.path)); - const baseFolder = this.ensureFolder(getDirectoryPath(file.fullPath)); - this.addFileOrFolderInFolder(baseFolder, file); - } - else { - const fullPath = getNormalizedAbsolutePath(fileOrFolder.path, this.currentDirectory); - this.ensureFolder(fullPath); - } - } - - private ensureFolder(fullPath: string): Folder { - const path = this.toPath(fullPath); - let folder = this.fs.get(path) as Folder; - if (!folder) { - folder = this.toFolder(fullPath); - const baseFullPath = getDirectoryPath(fullPath); - if (fullPath !== baseFullPath) { - // Add folder in the base folder - const baseFolder = this.ensureFolder(baseFullPath); - this.addFileOrFolderInFolder(baseFolder, folder); - } - else { - // root folder - Debug.assert(this.fs.size === 0); - this.fs.set(path, folder); - } - } - Debug.assert(isFolder(folder)); - return folder; - } - - private addFileOrFolderInFolder(folder: Folder, fileOrFolder: File | Folder) { - folder.entries.push(fileOrFolder); - this.fs.set(fileOrFolder.path, fileOrFolder); - - if (isFile(fileOrFolder)) { - this.invokeFileWatcher(fileOrFolder.fullPath, FileWatcherEventKind.Created); - } - this.invokeDirectoryWatcher(folder.fullPath, fileOrFolder.fullPath); - } - - private removeFileOrFolder(fileOrFolder: File | Folder, isRemovableLeafFolder: (folder: Folder) => boolean) { - const basePath = getDirectoryPath(fileOrFolder.path); - const baseFolder = this.fs.get(basePath) as Folder; - if (basePath !== fileOrFolder.path) { - Debug.assert(!!baseFolder); - filterMutate(baseFolder.entries, entry => entry !== fileOrFolder); - } - this.fs.delete(fileOrFolder.path); - - if (isFile(fileOrFolder)) { - this.invokeFileWatcher(fileOrFolder.fullPath, FileWatcherEventKind.Deleted); - } - else { - Debug.assert(fileOrFolder.entries.length === 0); - invokeDirectoryWatcher(this.watchedDirectories.get(fileOrFolder.path), () => this.getRelativePathToDirectory(fileOrFolder.fullPath, fileOrFolder.fullPath)); - invokeDirectoryWatcher(this.watchedDirectoriesRecursive.get(fileOrFolder.path), () => this.getRelativePathToDirectory(fileOrFolder.fullPath, fileOrFolder.fullPath)); - } - - if (basePath !== fileOrFolder.path) { - if (baseFolder.entries.length === 0 && isRemovableLeafFolder(baseFolder)) { - this.removeFileOrFolder(baseFolder, isRemovableLeafFolder); - } - else { - this.invokeRecursiveDirectoryWatcher(baseFolder.fullPath, fileOrFolder.fullPath); - } - } - } - - private invokeFileWatcher(fileFullPath: string, eventId: FileWatcherEventKind) { - const callbacks = this.watchedFiles.get(this.toPath(fileFullPath)); - invokeFileWatcher(callbacks, getBaseFileName(fileFullPath), eventId); - } - - private getRelativePathToDirectory(directoryFullPath: string, fileFullPath: string) { - return getRelativePathToDirectoryOrUrl(directoryFullPath, fileFullPath, this.currentDirectory, this.getCanonicalFileName, /*isAbsolutePathAnUrl*/ false); - } - - private invokeDirectoryWatcher(folderFullPath: string, fileName: string) { - invokeDirectoryWatcher(this.watchedDirectories.get(this.toPath(folderFullPath)), () => this.getRelativePathToDirectory(folderFullPath, fileName)); - this.invokeRecursiveDirectoryWatcher(folderFullPath, fileName); - } - - private invokeRecursiveDirectoryWatcher(fullPath: string, fileName: string) { - invokeDirectoryWatcher(this.watchedDirectoriesRecursive.get(this.toPath(fullPath)), () => this.getRelativePathToDirectory(fullPath, fileName)); - const basePath = getDirectoryPath(fullPath); - if (this.getCanonicalFileName(fullPath) !== this.getCanonicalFileName(basePath)) { - this.invokeRecursiveDirectoryWatcher(basePath, fileName); - } - } - - private toFile(fileOrFolder: FileOrFolder): File { - const fullPath = getNormalizedAbsolutePath(fileOrFolder.path, this.currentDirectory); - return { - path: this.toPath(fullPath), - content: fileOrFolder.content, - fullPath, - fileSize: fileOrFolder.fileSize - }; - } - - private toFolder(path: string): Folder { - const fullPath = getNormalizedAbsolutePath(path, this.currentDirectory); - return { - path: this.toPath(fullPath), - entries: [], - fullPath - }; - } - - fileExists(s: string) { - const path = this.toFullPath(s); - return isFile(this.fs.get(path)); - } - - getFileSize(s: string) { - const path = this.toFullPath(s); - const entry = this.fs.get(path); - if (isFile(entry)) { - return entry.fileSize ? entry.fileSize : entry.content.length; - } - return undefined; - } - - directoryExists(s: string) { - const path = this.toFullPath(s); - return isFolder(this.fs.get(path)); - } - - getDirectories(s: string) { - const path = this.toFullPath(s); - const folder = this.fs.get(path); - if (isFolder(folder)) { - return mapDefined(folder.entries, entry => isFolder(entry) ? getBaseFileName(entry.fullPath) : undefined); - } - Debug.fail(folder ? "getDirectories called on file" : "getDirectories called on missing folder"); - return []; - } - - readDirectory(path: string, extensions?: ReadonlyArray, exclude?: ReadonlyArray, include?: ReadonlyArray, depth?: number): string[] { - return ts.matchFiles(this.toFullPath(path), extensions, exclude, include, this.useCaseSensitiveFileNames, this.getCurrentDirectory(), depth, (dir) => { - const directories: string[] = []; - const files: string[] = []; - const dirEntry = this.fs.get(this.toPath(dir)); - if (isFolder(dirEntry)) { - dirEntry.entries.forEach((entry) => { - if (isFolder(entry)) { - directories.push(getBaseFileName(entry.fullPath)); - } - else if (isFile(entry)) { - files.push(getBaseFileName(entry.fullPath)); - } - else { - Debug.fail("Unknown entry"); - } - }); - } - return { directories, files }; - }); - } - - watchDirectory(directoryName: string, callback: DirectoryWatcherCallback, recursive: boolean): DirectoryWatcher { - const path = this.toFullPath(directoryName); - const map = recursive ? this.watchedDirectoriesRecursive : this.watchedDirectories; - map.add(path, callback); - return { - referenceCount: 0, - directoryName, - close: () => map.remove(path, callback) - }; - } - - createHash(s: string): string { - return Harness.LanguageService.mockHash(s); - } - - watchFile(fileName: string, callback: FileWatcherCallback) { - const path = this.toFullPath(fileName); - this.watchedFiles.add(path, callback); - return { close: () => this.watchedFiles.remove(path, callback) }; - } - - // TOOD: record and invoke callbacks to simulate timer events - setTimeout(callback: TimeOutCallback, _time: number, ...args: any[]) { - return this.timeoutCallbacks.register(callback, args); - } - - clearTimeout(timeoutId: any): void { - this.timeoutCallbacks.unregister(timeoutId); - } - - checkTimeoutQueueLengthAndRun(expected: number) { - this.checkTimeoutQueueLength(expected); - this.runQueuedTimeoutCallbacks(); - } - - checkTimeoutQueueLength(expected: number) { - const callbacksCount = this.timeoutCallbacks.count(); - assert.equal(callbacksCount, expected, `expected ${expected} timeout callbacks queued but found ${callbacksCount}.`); - } - - runQueuedTimeoutCallbacks() { - this.timeoutCallbacks.invoke(); - } - - runQueuedImmediateCallbacks() { - this.immediateCallbacks.invoke(); - } - - setImmediate(callback: TimeOutCallback, _time: number, ...args: any[]) { - return this.immediateCallbacks.register(callback, args); - } - - clearImmediate(timeoutId: any): void { - this.immediateCallbacks.unregister(timeoutId); - } - - createDirectory(directoryName: string): void { - const folder = this.toFolder(directoryName); - - // base folder has to be present - const base = getDirectoryPath(folder.fullPath); - const baseFolder = this.fs.get(base) as Folder; - Debug.assert(isFolder(baseFolder)); - - Debug.assert(!this.fs.get(folder.path), isFile(this.fs.get(folder.path)) ? `Found the file ${folder.path}` : `Found the folder ${folder.path}`); - this.addFileOrFolderInFolder(baseFolder, folder); - } - - writeFile(path: string, content: string): void { - const file = this.toFile({ path, content }); - - // base folder has to be present - const base = getDirectoryPath(file.fullPath); - const folder = this.fs.get(base) as Folder; - Debug.assert(isFolder(folder)); - - this.addFileOrFolderInFolder(folder, file); - } - - write(message: string) { - this.output.push(message); - } - - getOutput(): ReadonlyArray { - return this.output; - } - - clearOutput() { - clear(this.output); - } - - readonly readFile = (s: string) => (this.fs.get(this.toFullPath(s))).content; - readonly resolvePath = (s: string) => s; - readonly getExecutingFilePath = () => this.executingFilePath; - readonly getCurrentDirectory = () => this.currentDirectory; - readonly exit = notImplemented; - readonly getEnvironmentVariable = notImplemented; - } - /** * Test server cancellation token used to mock host token cancellation requests. * The cancelAfterRequest constructor param specifies how many isCancellationRequested() calls diff --git a/src/harness/virtualFileSystemWithWatch.ts b/src/harness/virtualFileSystemWithWatch.ts new file mode 100644 index 0000000000..f37d685851 --- /dev/null +++ b/src/harness/virtualFileSystemWithWatch.ts @@ -0,0 +1,492 @@ +/// + +namespace ts.TestFSWithWatch { + export const { content: libFileContent } = Harness.getDefaultLibraryFile(Harness.IO); + export const libFile: FileOrFolder = { + path: "/a/lib/lib.d.ts", + content: libFileContent + }; + + export const safeList = { + path: "/safeList.json", + content: JSON.stringify({ + commander: "commander", + express: "express", + jquery: "jquery", + lodash: "lodash", + moment: "moment", + chroma: "chroma-js" + }) + }; + + export function getExecutingFilePathFromLibFile(): string { + return combinePaths(getDirectoryPath(libFile.path), "tsc.js"); + } + + export interface TestServerHostCreationParameters { + useCaseSensitiveFileNames?: boolean; + executingFilePath?: string; + currentDirectory?: string; + newLine?: string; + } + + export function createServerHost(fileOrFolderList: FileOrFolder[], params?: TestServerHostCreationParameters): TestServerHost { + if (!params) { + params = {}; + } + const host = new TestServerHost(/*withSafelist*/ true, + params.useCaseSensitiveFileNames !== undefined ? params.useCaseSensitiveFileNames : false, + params.executingFilePath || getExecutingFilePathFromLibFile(), + params.currentDirectory || "/", + fileOrFolderList, + params.newLine); + return host; + } + + export interface FileOrFolder { + path: string; + content?: string; + fileSize?: number; + } + + export interface FSEntry { + path: Path; + fullPath: string; + } + + export interface File extends FSEntry { + content: string; + fileSize?: number; + } + + export interface Folder extends FSEntry { + entries: FSEntry[]; + } + + export function isFolder(s: FSEntry): s is Folder { + return s && isArray((s).entries); + } + + export function isFile(s: FSEntry): s is File { + return s && isString((s).content); + } + + function invokeDirectoryWatcher(callbacks: DirectoryWatcherCallback[], getRelativeFilePath: () => string) { + if (callbacks) { + const cbs = callbacks.slice(); + for (const cb of cbs) { + const fileName = getRelativeFilePath(); + cb(fileName); + } + } + } + + function invokeFileWatcher(callbacks: FileWatcherCallback[], fileName: string, eventId: FileWatcherEventKind) { + if (callbacks) { + const cbs = callbacks.slice(); + for (const cb of cbs) { + cb(fileName, eventId); + } + } + } + + export function checkMapKeys(caption: string, map: Map, expectedKeys: string[]) { + assert.equal(map.size, expectedKeys.length, `${caption}: incorrect size of map: Actual keys: ${arrayFrom(map.keys())} Expected: ${expectedKeys}`); + for (const name of expectedKeys) { + assert.isTrue(map.has(name), `${caption} is expected to contain ${name}, actual keys: ${arrayFrom(map.keys())}`); + } + } + + export function checkFileNames(caption: string, actualFileNames: string[], expectedFileNames: string[]) { + assert.equal(actualFileNames.length, expectedFileNames.length, `${caption}: incorrect actual number of files, expected ${JSON.stringify(expectedFileNames)}, got ${actualFileNames}`); + for (const f of expectedFileNames) { + assert.isTrue(contains(actualFileNames, f), `${caption}: expected to find ${f} in ${JSON.stringify(actualFileNames)}`); + } + } + + export function checkWatchedFiles(host: TestServerHost, expectedFiles: string[]) { + checkMapKeys("watchedFiles", host.watchedFiles, expectedFiles); + } + + export function checkWatchedDirectories(host: TestServerHost, expectedDirectories: string[], recursive = false) { + checkMapKeys("watchedDirectories", recursive ? host.watchedDirectoriesRecursive : host.watchedDirectories, expectedDirectories); + } + + export class Callbacks { + private map: TimeOutCallback[] = []; + private nextId = 1; + + register(cb: (...args: any[]) => void, args: any[]) { + const timeoutId = this.nextId; + this.nextId++; + this.map[timeoutId] = cb.bind(/*this*/ undefined, ...args); + return timeoutId; + } + + unregister(id: any) { + if (typeof id === "number") { + delete this.map[id]; + } + } + + count() { + let n = 0; + for (const _ in this.map) { + n++; + } + return n; + } + + invoke() { + // Note: invoking a callback may result in new callbacks been queued, + // so do not clear the entire callback list regardless. Only remove the + // ones we have invoked. + for (const key in this.map) { + this.map[key](); + delete this.map[key]; + } + } + } + + export type TimeOutCallback = () => any; + + export class TestServerHost implements server.ServerHost { + args: string[] = []; + + private readonly output: string[] = []; + + private fs: Map = createMap(); + private getCanonicalFileName: (s: string) => string; + private toPath: (f: string) => Path; + private timeoutCallbacks = new Callbacks(); + private immediateCallbacks = new Callbacks(); + + readonly watchedDirectories = createMultiMap(); + readonly watchedDirectoriesRecursive = createMultiMap(); + readonly watchedFiles = createMultiMap(); + + constructor(public withSafeList: boolean, public useCaseSensitiveFileNames: boolean, private executingFilePath: string, private currentDirectory: string, fileOrFolderList: FileOrFolder[], public readonly newLine = "\n") { + this.getCanonicalFileName = createGetCanonicalFileName(useCaseSensitiveFileNames); + this.toPath = s => toPath(s, currentDirectory, this.getCanonicalFileName); + + this.reloadFS(fileOrFolderList); + } + + private toFullPath(s: string) { + const fullPath = getNormalizedAbsolutePath(s, this.currentDirectory); + return this.toPath(fullPath); + } + + reloadFS(fileOrFolderList: FileOrFolder[]) { + const mapNewLeaves = createMap(); + const isNewFs = this.fs.size === 0; + // always inject safelist file in the list of files + for (const fileOrFolder of fileOrFolderList.concat(this.withSafeList ? safeList : [])) { + const path = this.toFullPath(fileOrFolder.path); + mapNewLeaves.set(path, true); + // If its a change + const currentEntry = this.fs.get(path); + if (currentEntry) { + if (isFile(currentEntry)) { + if (isString(fileOrFolder.content)) { + // Update file + if (currentEntry.content !== fileOrFolder.content) { + currentEntry.content = fileOrFolder.content; + this.invokeFileWatcher(currentEntry.fullPath, FileWatcherEventKind.Changed); + } + } + else { + // TODO: Changing from file => folder + } + } + else { + // Folder + if (isString(fileOrFolder.content)) { + // TODO: Changing from folder => file + } + else { + // Folder update: Nothing to do. + } + } + } + else { + this.ensureFileOrFolder(fileOrFolder); + } + } + + if (!isNewFs) { + this.fs.forEach((fileOrFolder, path) => { + // If this entry is not from the new file or folder + if (!mapNewLeaves.get(path)) { + // Leaf entries that arent in new list => remove these + if (isFile(fileOrFolder) || isFolder(fileOrFolder) && fileOrFolder.entries.length === 0) { + this.removeFileOrFolder(fileOrFolder, folder => !mapNewLeaves.get(folder.path)); + } + } + }); + } + } + + ensureFileOrFolder(fileOrFolder: FileOrFolder) { + if (isString(fileOrFolder.content)) { + const file = this.toFile(fileOrFolder); + Debug.assert(!this.fs.get(file.path)); + const baseFolder = this.ensureFolder(getDirectoryPath(file.fullPath)); + this.addFileOrFolderInFolder(baseFolder, file); + } + else { + const fullPath = getNormalizedAbsolutePath(fileOrFolder.path, this.currentDirectory); + this.ensureFolder(fullPath); + } + } + + private ensureFolder(fullPath: string): Folder { + const path = this.toPath(fullPath); + let folder = this.fs.get(path) as Folder; + if (!folder) { + folder = this.toFolder(fullPath); + const baseFullPath = getDirectoryPath(fullPath); + if (fullPath !== baseFullPath) { + // Add folder in the base folder + const baseFolder = this.ensureFolder(baseFullPath); + this.addFileOrFolderInFolder(baseFolder, folder); + } + else { + // root folder + Debug.assert(this.fs.size === 0); + this.fs.set(path, folder); + } + } + Debug.assert(isFolder(folder)); + return folder; + } + + private addFileOrFolderInFolder(folder: Folder, fileOrFolder: File | Folder) { + folder.entries.push(fileOrFolder); + this.fs.set(fileOrFolder.path, fileOrFolder); + + if (isFile(fileOrFolder)) { + this.invokeFileWatcher(fileOrFolder.fullPath, FileWatcherEventKind.Created); + } + this.invokeDirectoryWatcher(folder.fullPath, fileOrFolder.fullPath); + } + + private removeFileOrFolder(fileOrFolder: File | Folder, isRemovableLeafFolder: (folder: Folder) => boolean) { + const basePath = getDirectoryPath(fileOrFolder.path); + const baseFolder = this.fs.get(basePath) as Folder; + if (basePath !== fileOrFolder.path) { + Debug.assert(!!baseFolder); + filterMutate(baseFolder.entries, entry => entry !== fileOrFolder); + } + this.fs.delete(fileOrFolder.path); + + if (isFile(fileOrFolder)) { + this.invokeFileWatcher(fileOrFolder.fullPath, FileWatcherEventKind.Deleted); + } + else { + Debug.assert(fileOrFolder.entries.length === 0); + invokeDirectoryWatcher(this.watchedDirectories.get(fileOrFolder.path), () => this.getRelativePathToDirectory(fileOrFolder.fullPath, fileOrFolder.fullPath)); + invokeDirectoryWatcher(this.watchedDirectoriesRecursive.get(fileOrFolder.path), () => this.getRelativePathToDirectory(fileOrFolder.fullPath, fileOrFolder.fullPath)); + } + + if (basePath !== fileOrFolder.path) { + if (baseFolder.entries.length === 0 && isRemovableLeafFolder(baseFolder)) { + this.removeFileOrFolder(baseFolder, isRemovableLeafFolder); + } + else { + this.invokeRecursiveDirectoryWatcher(baseFolder.fullPath, fileOrFolder.fullPath); + } + } + } + + private invokeFileWatcher(fileFullPath: string, eventId: FileWatcherEventKind) { + const callbacks = this.watchedFiles.get(this.toPath(fileFullPath)); + invokeFileWatcher(callbacks, getBaseFileName(fileFullPath), eventId); + } + + private getRelativePathToDirectory(directoryFullPath: string, fileFullPath: string) { + return getRelativePathToDirectoryOrUrl(directoryFullPath, fileFullPath, this.currentDirectory, this.getCanonicalFileName, /*isAbsolutePathAnUrl*/ false); + } + + private invokeDirectoryWatcher(folderFullPath: string, fileName: string) { + invokeDirectoryWatcher(this.watchedDirectories.get(this.toPath(folderFullPath)), () => this.getRelativePathToDirectory(folderFullPath, fileName)); + this.invokeRecursiveDirectoryWatcher(folderFullPath, fileName); + } + + private invokeRecursiveDirectoryWatcher(fullPath: string, fileName: string) { + invokeDirectoryWatcher(this.watchedDirectoriesRecursive.get(this.toPath(fullPath)), () => this.getRelativePathToDirectory(fullPath, fileName)); + const basePath = getDirectoryPath(fullPath); + if (this.getCanonicalFileName(fullPath) !== this.getCanonicalFileName(basePath)) { + this.invokeRecursiveDirectoryWatcher(basePath, fileName); + } + } + + private toFile(fileOrFolder: FileOrFolder): File { + const fullPath = getNormalizedAbsolutePath(fileOrFolder.path, this.currentDirectory); + return { + path: this.toPath(fullPath), + content: fileOrFolder.content, + fullPath, + fileSize: fileOrFolder.fileSize + }; + } + + private toFolder(path: string): Folder { + const fullPath = getNormalizedAbsolutePath(path, this.currentDirectory); + return { + path: this.toPath(fullPath), + entries: [], + fullPath + }; + } + + fileExists(s: string) { + const path = this.toFullPath(s); + return isFile(this.fs.get(path)); + } + + getFileSize(s: string) { + const path = this.toFullPath(s); + const entry = this.fs.get(path); + if (isFile(entry)) { + return entry.fileSize ? entry.fileSize : entry.content.length; + } + return undefined; + } + + directoryExists(s: string) { + const path = this.toFullPath(s); + return isFolder(this.fs.get(path)); + } + + getDirectories(s: string) { + const path = this.toFullPath(s); + const folder = this.fs.get(path); + if (isFolder(folder)) { + return mapDefined(folder.entries, entry => isFolder(entry) ? getBaseFileName(entry.fullPath) : undefined); + } + Debug.fail(folder ? "getDirectories called on file" : "getDirectories called on missing folder"); + return []; + } + + readDirectory(path: string, extensions?: ReadonlyArray, exclude?: ReadonlyArray, include?: ReadonlyArray, depth?: number): string[] { + return ts.matchFiles(this.toFullPath(path), extensions, exclude, include, this.useCaseSensitiveFileNames, this.getCurrentDirectory(), depth, (dir) => { + const directories: string[] = []; + const files: string[] = []; + const dirEntry = this.fs.get(this.toPath(dir)); + if (isFolder(dirEntry)) { + dirEntry.entries.forEach((entry) => { + if (isFolder(entry)) { + directories.push(getBaseFileName(entry.fullPath)); + } + else if (isFile(entry)) { + files.push(getBaseFileName(entry.fullPath)); + } + else { + Debug.fail("Unknown entry"); + } + }); + } + return { directories, files }; + }); + } + + watchDirectory(directoryName: string, callback: DirectoryWatcherCallback, recursive: boolean): DirectoryWatcher { + const path = this.toFullPath(directoryName); + const map = recursive ? this.watchedDirectoriesRecursive : this.watchedDirectories; + map.add(path, callback); + return { + referenceCount: 0, + directoryName, + close: () => map.remove(path, callback) + }; + } + + createHash(s: string): string { + return Harness.mockHash(s); + } + + watchFile(fileName: string, callback: FileWatcherCallback) { + const path = this.toFullPath(fileName); + this.watchedFiles.add(path, callback); + return { close: () => this.watchedFiles.remove(path, callback) }; + } + + // TOOD: record and invoke callbacks to simulate timer events + setTimeout(callback: TimeOutCallback, _time: number, ...args: any[]) { + return this.timeoutCallbacks.register(callback, args); + } + + clearTimeout(timeoutId: any): void { + this.timeoutCallbacks.unregister(timeoutId); + } + + checkTimeoutQueueLengthAndRun(expected: number) { + this.checkTimeoutQueueLength(expected); + this.runQueuedTimeoutCallbacks(); + } + + checkTimeoutQueueLength(expected: number) { + const callbacksCount = this.timeoutCallbacks.count(); + assert.equal(callbacksCount, expected, `expected ${expected} timeout callbacks queued but found ${callbacksCount}.`); + } + + runQueuedTimeoutCallbacks() { + this.timeoutCallbacks.invoke(); + } + + runQueuedImmediateCallbacks() { + this.immediateCallbacks.invoke(); + } + + setImmediate(callback: TimeOutCallback, _time: number, ...args: any[]) { + return this.immediateCallbacks.register(callback, args); + } + + clearImmediate(timeoutId: any): void { + this.immediateCallbacks.unregister(timeoutId); + } + + createDirectory(directoryName: string): void { + const folder = this.toFolder(directoryName); + + // base folder has to be present + const base = getDirectoryPath(folder.fullPath); + const baseFolder = this.fs.get(base) as Folder; + Debug.assert(isFolder(baseFolder)); + + Debug.assert(!this.fs.get(folder.path), isFile(this.fs.get(folder.path)) ? `Found the file ${folder.path}` : `Found the folder ${folder.path}`); + this.addFileOrFolderInFolder(baseFolder, folder); + } + + writeFile(path: string, content: string): void { + const file = this.toFile({ path, content }); + + // base folder has to be present + const base = getDirectoryPath(file.fullPath); + const folder = this.fs.get(base) as Folder; + Debug.assert(isFolder(folder)); + + this.addFileOrFolderInFolder(folder, file); + } + + write(message: string) { + this.output.push(message); + } + + getOutput(): ReadonlyArray { + return this.output; + } + + clearOutput() { + clear(this.output); + } + + readonly readFile = (s: string) => (this.fs.get(this.toFullPath(s))).content; + readonly resolvePath = (s: string) => s; + readonly getExecutingFilePath = () => this.executingFilePath; + readonly getCurrentDirectory = () => this.currentDirectory; + readonly exit = notImplemented; + readonly getEnvironmentVariable = notImplemented; + } +}