/// namespace ts.TestFSWithWatch { 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" }) }; function getExecutingFilePathFromLibFile(): string { return combinePaths(getDirectoryPath(libFile.path), "tsc.js"); } interface TestServerHostCreationParameters { useCaseSensitiveFileNames?: boolean; executingFilePath?: string; currentDirectory?: string; newLine?: string; } export function createWatchedSystem(fileOrFolderList: FileOrFolder[], params?: TestServerHostCreationParameters): TestServerHost { if (!params) { params = {}; } const host = new TestServerHost(/*withSafelist*/ false, params.useCaseSensitiveFileNames !== undefined ? params.useCaseSensitiveFileNames : false, params.executingFilePath || getExecutingFilePathFromLibFile(), params.currentDirectory || "/", fileOrFolderList, params.newLine); return host; } 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; } interface FSEntry { path: Path; fullPath: string; } interface File extends FSEntry { content: string; fileSize?: number; } interface Folder extends FSEntry { entries: FSEntry[]; } function isFolder(s: FSEntry): s is Folder { return s && isArray((s).entries); } function isFile(s: FSEntry): s is File { return s && isString((s).content); } function invokeWatcherCallbacks(callbacks: T[], invokeCallback: (cb: T) => void): void { if (callbacks) { // The array copy is made to ensure that even if one of the callback removes the callbacks, // we dont miss any callbacks following it const cbs = callbacks.slice(); for (const cb of cbs) { invokeCallback(cb); } } } function getDiffInKeys(map: Map, expectedKeys: string[]) { if (map.size === expectedKeys.length) { return ""; } const notInActual: string[] = []; const duplicates: string[] = []; const seen = createMap(); forEach(expectedKeys, expectedKey => { if (seen.has(expectedKey)) { duplicates.push(expectedKey); return; } seen.set(expectedKey, true); if (!map.has(expectedKey)) { notInActual.push(expectedKey); } }); const inActualNotExpected: string[] = []; map.forEach((_value, key) => { if (!seen.has(key)) { inActualNotExpected.push(key); } seen.set(key, true); }); return `\n\nNotInActual: ${notInActual}\nDuplicates: ${duplicates}\nInActualButNotInExpected: ${inActualNotExpected}`; } 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}${getDiffInKeys(map, 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 function checkOutputContains(host: TestServerHost, expected: ReadonlyArray) { const mapExpected = arrayToSet(expected); const mapSeen = createMap(); for (const f of host.getOutput()) { assert.isUndefined(mapSeen.get(f), `Already found ${f} in ${JSON.stringify(host.getOutput())}`); if (mapExpected.has(f)) { mapExpected.delete(f); mapSeen.set(f, true); } } assert.equal(mapExpected.size, 0, `Output has missing ${JSON.stringify(flatMapIter(mapExpected.keys(), key => key))} in ${JSON.stringify(host.getOutput())}`); } export function checkOutputDoesNotContain(host: TestServerHost, expectedToBeAbsent: string[] | ReadonlyArray) { const mapExpectedToBeAbsent = arrayToSet(expectedToBeAbsent); for (const f of host.getOutput()) { assert.isFalse(mapExpectedToBeAbsent.has(f), `Contains ${f} in ${JSON.stringify(host.getOutput())}`); } } 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]; } } } type TimeOutCallback = () => any; export type TestFileWatcher = { cb: FileWatcherCallback; fileName: string; }; export type TestDirectoryWatcher = { cb: DirectoryWatcherCallback; directoryName: string; }; 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); } toNormalizedAbsolutePath(s: string) { return getNormalizedAbsolutePath(s, this.currentDirectory); } toFullPath(s: string) { return this.toPath(this.toNormalizedAbsolutePath(s)); } 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); const relativePath = this.getRelativePathToDirectory(fileOrFolder.fullPath, fileOrFolder.fullPath); // Invoke directory and recursive directory watcher for the folder // Here we arent invoking recursive directory watchers for the base folders // since that is something we would want to do for both file as well as folder we are deleting invokeWatcherCallbacks(this.watchedDirectories.get(fileOrFolder.path), cb => this.directoryCallback(cb, relativePath)); invokeWatcherCallbacks(this.watchedDirectoriesRecursive.get(fileOrFolder.path), cb => this.directoryCallback(cb, relativePath)); } 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, eventKind: FileWatcherEventKind) { const callbacks = this.watchedFiles.get(this.toPath(fileFullPath)); invokeWatcherCallbacks(callbacks, ({ cb, fileName }) => cb(fileName, eventKind)); } private getRelativePathToDirectory(directoryFullPath: string, fileFullPath: string) { return getRelativePathToDirectoryOrUrl(directoryFullPath, fileFullPath, this.currentDirectory, this.getCanonicalFileName, /*isAbsolutePathAnUrl*/ false); } /** * This will call the directory watcher for the folderFullPath and recursive directory watchers for this and base folders */ private invokeDirectoryWatcher(folderFullPath: string, fileName: string) { const relativePath = this.getRelativePathToDirectory(folderFullPath, fileName); invokeWatcherCallbacks(this.watchedDirectories.get(this.toPath(folderFullPath)), cb => this.directoryCallback(cb, relativePath)); this.invokeRecursiveDirectoryWatcher(folderFullPath, fileName); } private directoryCallback({ cb, directoryName }: TestDirectoryWatcher, relativePath: string) { cb(combinePaths(directoryName, relativePath)); } /** * This will call the recursive directory watcher for this directory as well as all the base directories */ private invokeRecursiveDirectoryWatcher(fullPath: string, fileName: string) { const relativePath = this.getRelativePathToDirectory(fullPath, fileName); invokeWatcherCallbacks(this.watchedDirectoriesRecursive.get(this.toPath(fullPath)), cb => this.directoryCallback(cb, relativePath)); 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)); } readFile(s: string) { const fsEntry = this.fs.get(this.toFullPath(s)); return isFile(fsEntry) ? fsEntry.content : undefined; } 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.toNormalizedAbsolutePath(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, cb: DirectoryWatcherCallback, recursive: boolean): DirectoryWatcher { const path = this.toFullPath(directoryName); const map = recursive ? this.watchedDirectoriesRecursive : this.watchedDirectories; const callback: TestDirectoryWatcher = { cb, directoryName }; map.add(path, callback); return { referenceCount: 0, directoryName, close: () => map.remove(path, callback) }; } createHash(s: string): string { return Harness.mockHash(s); } watchFile(fileName: string, cb: FileWatcherCallback) { const path = this.toFullPath(fileName); const callback: TestFileWatcher = { fileName, cb }; 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() { try { this.timeoutCallbacks.invoke(); } catch (e) { if (e.message === this.existMessage) { return; } throw e; } } 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)); 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 existMessage = "System Exit"; exitCode: number; readonly resolvePath = (s: string) => s; readonly getExecutingFilePath = () => this.executingFilePath; readonly getCurrentDirectory = () => this.currentDirectory; exit(exitCode?: number) { this.exitCode = exitCode; throw new Error(this.existMessage); } readonly getEnvironmentVariable = notImplemented; } }