1namespace Playback { // eslint-disable-line local/one-namespace-per-file 2 interface FileInformation { 3 contents?: string; 4 contentsPath?: string; 5 codepage: number; 6 bom?: string; 7 } 8 9 interface FindFileResult { 10 } 11 12 interface IoLogFile { 13 path: string; 14 codepage: number; 15 result?: FileInformation; 16 } 17 18 export interface IoLog { 19 timestamp: string; 20 arguments: string[]; 21 executingPath: string; 22 currentDirectory: string; 23 useCustomLibraryFile?: boolean; 24 filesRead: IoLogFile[]; 25 filesWritten: { 26 path: string; 27 contents?: string; 28 contentsPath?: string; 29 bom: boolean; 30 }[]; 31 filesDeleted: string[]; 32 filesAppended: { 33 path: string; 34 contents?: string; 35 contentsPath?: string; 36 }[]; 37 fileExists: { 38 path: string; 39 result?: boolean; 40 }[]; 41 filesFound: { 42 path: string; 43 pattern: string; 44 result?: FindFileResult; 45 }[]; 46 dirs: { 47 path: string; 48 re: string; 49 re_m: boolean; 50 re_g: boolean; 51 re_i: boolean; 52 opts: { recursive?: boolean; }; 53 result?: string[]; 54 }[]; 55 dirExists: { 56 path: string; 57 result?: boolean; 58 }[]; 59 dirsCreated: string[]; 60 pathsResolved: { 61 path: string; 62 result?: string; 63 }[]; 64 directoriesRead: { 65 path: string, 66 extensions: readonly string[] | undefined, 67 exclude: readonly string[] | undefined, 68 include: readonly string[] | undefined, 69 depth: number | undefined, 70 result: readonly string[], 71 }[]; 72 useCaseSensitiveFileNames?: boolean; 73 } 74 75 interface PlaybackControl { 76 startReplayFromFile(logFileName: string): void; 77 startReplayFromString(logContents: string): void; 78 startReplayFromData(log: IoLog): void; 79 endReplay(): void; 80 startRecord(logFileName: string): void; 81 endRecord(): void; 82 } 83 84 let recordLog: IoLog | undefined; 85 let replayLog: IoLog | undefined; 86 let replayFilesRead: ts.ESMap<string, IoLogFile> | undefined; 87 let recordLogFileNameBase = ""; 88 89 interface Memoized<T> { 90 (s: string): T; 91 reset(): void; 92 } 93 94 function memoize<T>(func: (s: string) => T): Memoized<T> { 95 let lookup: { [s: string]: T } = {}; 96 const run: Memoized<T> = ((s: string) => { 97 if (ts.hasProperty(lookup, s)) return lookup[s]; 98 return lookup[s] = func(s); 99 }) as Memoized<T>; 100 run.reset = () => { 101 lookup = undefined!; // TODO: GH#18217 102 }; 103 104 return run; 105 } 106 107 export interface PlaybackIO extends Harness.IO, PlaybackControl { } 108 109 export interface PlaybackSystem extends ts.System, PlaybackControl { } 110 111 function createEmptyLog(): IoLog { 112 return { 113 timestamp: (new Date()).toString(), 114 arguments: [], 115 currentDirectory: "", 116 filesRead: [], 117 directoriesRead: [], 118 filesWritten: [], 119 filesDeleted: [], 120 filesAppended: [], 121 fileExists: [], 122 filesFound: [], 123 dirs: [], 124 dirExists: [], 125 dirsCreated: [], 126 pathsResolved: [], 127 executingPath: "" 128 }; 129 } 130 131 export function newStyleLogIntoOldStyleLog(log: IoLog, host: ts.System | Harness.IO, baseName: string) { 132 for (const file of log.filesAppended) { 133 if (file.contentsPath) { 134 file.contents = host.readFile(ts.combinePaths(baseName, file.contentsPath)); 135 delete file.contentsPath; 136 } 137 } 138 for (const file of log.filesWritten) { 139 if (file.contentsPath) { 140 file.contents = host.readFile(ts.combinePaths(baseName, file.contentsPath)); 141 delete file.contentsPath; 142 } 143 } 144 for (const file of log.filesRead) { 145 const result = file.result!; // TODO: GH#18217 146 if (result.contentsPath) { 147 // `readFile` strips away a BOM (and actually reinerprets the file contents according to the correct encoding) 148 // - but this has the unfortunate sideeffect of removing the BOM from any outputs based on the file, so we readd it here. 149 result.contents = (result.bom || "") + host.readFile(ts.combinePaths(baseName, result.contentsPath)); 150 delete result.contentsPath; 151 } 152 } 153 return log; 154 } 155 156 const canonicalizeForHarness = ts.createGetCanonicalFileName(/*caseSensitive*/ false); // This is done so tests work on windows _and_ linux 157 function sanitizeTestFilePath(name: string) { 158 const path = ts.toPath(ts.normalizeSlashes(name.replace(/[\^<>:"|?*%]/g, "_")).replace(/\.\.\//g, "__dotdot/"), "", canonicalizeForHarness); 159 if (ts.startsWith(path, "/")) { 160 return path.substring(1); 161 } 162 return path; 163 } 164 165 export function oldStyleLogIntoNewStyleLog(log: IoLog, writeFile: typeof Harness.IO.writeFile, baseTestName: string) { 166 if (log.filesAppended) { 167 for (const file of log.filesAppended) { 168 if (file.contents !== undefined) { 169 file.contentsPath = ts.combinePaths("appended", sanitizeTestFilePath(file.path)); 170 writeFile(ts.combinePaths(baseTestName, file.contentsPath), file.contents); 171 delete file.contents; 172 } 173 } 174 } 175 if (log.filesWritten) { 176 for (const file of log.filesWritten) { 177 if (file.contents !== undefined) { 178 file.contentsPath = ts.combinePaths("written", sanitizeTestFilePath(file.path)); 179 writeFile(ts.combinePaths(baseTestName, file.contentsPath), file.contents); 180 delete file.contents; 181 } 182 } 183 } 184 if (log.filesRead) { 185 for (const file of log.filesRead) { 186 const result = file.result!; // TODO: GH#18217 187 const { contents } = result; 188 if (contents !== undefined) { 189 result.contentsPath = ts.combinePaths("read", sanitizeTestFilePath(file.path)); 190 writeFile(ts.combinePaths(baseTestName, result.contentsPath), contents); 191 const len = contents.length; 192 if (len >= 2 && contents.charCodeAt(0) === 0xfeff) { 193 result.bom = "\ufeff"; 194 } 195 if (len >= 2 && contents.charCodeAt(0) === 0xfffe) { 196 result.bom = "\ufffe"; 197 } 198 if (len >= 3 && contents.charCodeAt(0) === 0xefbb && contents.charCodeAt(1) === 0xbf) { 199 result.bom = "\uefbb\xbf"; 200 } 201 delete result.contents; 202 } 203 } 204 } 205 return log; 206 } 207 208 export function initWrapper(...[wrapper, underlying]: [PlaybackSystem, ts.System] | [PlaybackIO, Harness.IO]): void { 209 ts.forEach(Object.keys(underlying), prop => { 210 (wrapper as any)[prop] = (underlying as any)[prop]; 211 }); 212 213 wrapper.startReplayFromString = logString => { 214 wrapper.startReplayFromData(JSON.parse(logString)); 215 }; 216 wrapper.startReplayFromData = log => { 217 replayLog = log; 218 // Remove non-found files from the log (shouldn't really need them, but we still record them for diagnostic purposes) 219 replayLog.filesRead = replayLog.filesRead.filter(f => f.result!.contents !== undefined); 220 replayFilesRead = new ts.Map(); 221 for (const file of replayLog.filesRead) { 222 replayFilesRead.set(ts.normalizeSlashes(file.path).toLowerCase(), file); 223 } 224 }; 225 226 wrapper.endReplay = () => { 227 replayLog = undefined; 228 replayFilesRead = undefined; 229 }; 230 231 wrapper.startRecord = (fileNameBase) => { 232 recordLogFileNameBase = fileNameBase; 233 recordLog = createEmptyLog(); 234 recordLog.useCaseSensitiveFileNames = typeof underlying.useCaseSensitiveFileNames === "function" ? underlying.useCaseSensitiveFileNames() : underlying.useCaseSensitiveFileNames; 235 if (typeof underlying.args !== "function") { 236 recordLog.arguments = underlying.args; 237 } 238 }; 239 240 wrapper.startReplayFromFile = logFn => { 241 wrapper.startReplayFromString(underlying.readFile(logFn)!); 242 }; 243 wrapper.endRecord = () => { 244 if (recordLog !== undefined) { 245 let i = 0; 246 const getBase = () => recordLogFileNameBase + i; 247 while (underlying.fileExists(ts.combinePaths(getBase(), "test.json"))) i++; 248 const newLog = oldStyleLogIntoNewStyleLog(recordLog, (path, str) => underlying.writeFile(path, str), getBase()); 249 underlying.writeFile(ts.combinePaths(getBase(), "test.json"), JSON.stringify(newLog, null, 4)); // eslint-disable-line no-null/no-null 250 const syntheticTsconfig = generateTsconfig(newLog); 251 if (syntheticTsconfig) { 252 underlying.writeFile(ts.combinePaths(getBase(), "tsconfig.json"), JSON.stringify(syntheticTsconfig, null, 4)); // eslint-disable-line no-null/no-null 253 } 254 recordLog = undefined; 255 } 256 }; 257 258 function generateTsconfig(newLog: IoLog): undefined | { compilerOptions: ts.CompilerOptions, files: string[] } { 259 if (newLog.filesRead.some(file => /tsconfig.+json$/.test(file.path))) { 260 return; 261 } 262 const files = []; 263 for (const file of newLog.filesRead) { 264 const result = file.result!; 265 if (result.contentsPath && 266 Harness.isDefaultLibraryFile(result.contentsPath) && 267 /\.[tj]s$/.test(result.contentsPath)) { 268 files.push(result.contentsPath); 269 } 270 } 271 return { compilerOptions: ts.parseCommandLine(newLog.arguments).options, files }; 272 } 273 274 wrapper.fileExists = recordReplay(wrapper.fileExists, underlying)( 275 path => callAndRecord(underlying.fileExists(path), recordLog!.fileExists, { path }), 276 memoize(path => { 277 // If we read from the file, it must exist 278 if (findFileByPath(path, /*throwFileNotFoundError*/ false)) { 279 return true; 280 } 281 else { 282 return findResultByFields(replayLog!.fileExists, { path }, /*defaultValue*/ false)!; 283 } 284 }) 285 ); 286 287 wrapper.getExecutingFilePath = () => { 288 if (replayLog !== undefined) { 289 return replayLog.executingPath; 290 } 291 else if (recordLog !== undefined) { 292 return recordLog.executingPath = underlying.getExecutingFilePath(); 293 } 294 else { 295 return underlying.getExecutingFilePath(); 296 } 297 }; 298 299 wrapper.getCurrentDirectory = () => { 300 if (replayLog !== undefined) { 301 return replayLog.currentDirectory || ""; 302 } 303 else if (recordLog !== undefined) { 304 return recordLog.currentDirectory = underlying.getCurrentDirectory(); 305 } 306 else { 307 return underlying.getCurrentDirectory(); 308 } 309 }; 310 311 wrapper.resolvePath = recordReplay(wrapper.resolvePath, underlying)( 312 path => callAndRecord(underlying.resolvePath(path), recordLog!.pathsResolved, { path }), 313 memoize(path => findResultByFields(replayLog!.pathsResolved, { path }, !ts.isRootedDiskPath(ts.normalizeSlashes(path)) && replayLog!.currentDirectory ? replayLog!.currentDirectory + "/" + path : ts.normalizeSlashes(path)))); 314 315 wrapper.readFile = recordReplay(wrapper.readFile, underlying)( 316 (path: string) => { 317 const result = underlying.readFile(path); 318 const logEntry = { path, codepage: 0, result: { contents: result, codepage: 0 } }; 319 recordLog!.filesRead.push(logEntry); 320 return result; 321 }, 322 memoize(path => findFileByPath(path, /*throwFileNotFoundError*/ true)!.contents)); 323 324 wrapper.readDirectory = recordReplay(wrapper.readDirectory, underlying)( 325 (path, extensions, exclude, include, depth) => { 326 const result = (underlying as ts.System).readDirectory(path, extensions, exclude, include, depth); 327 recordLog!.directoriesRead.push({ path, extensions, exclude, include, depth, result }); 328 return result; 329 }, 330 path => { 331 // Because extensions is an array of all allowed extension, we will want to merge each of the replayLog.directoriesRead into one 332 // if each of the directoriesRead has matched path with the given path (directory with same path but different extension will considered 333 // different entry). 334 // TODO (yuisu): We can certainly remove these once we recapture the RWC using new API 335 const normalizedPath = ts.normalizePath(path).toLowerCase(); 336 return ts.flatMap(replayLog!.directoriesRead, directory => { 337 if (ts.normalizeSlashes(directory.path).toLowerCase() === normalizedPath) { 338 return directory.result; 339 } 340 }); 341 }); 342 343 wrapper.writeFile = recordReplay(wrapper.writeFile, underlying)( 344 (path: string, contents: string) => callAndRecord(underlying.writeFile(path, contents), recordLog!.filesWritten, { path, contents, bom: false }), 345 () => noOpReplay("writeFile")); 346 347 wrapper.exit = (exitCode) => { 348 if (recordLog !== undefined) { 349 wrapper.endRecord(); 350 } 351 underlying.exit(exitCode); 352 }; 353 354 wrapper.useCaseSensitiveFileNames = () => { 355 if (replayLog !== undefined) { 356 return !!replayLog.useCaseSensitiveFileNames; 357 } 358 return typeof underlying.useCaseSensitiveFileNames === "function" ? underlying.useCaseSensitiveFileNames() : underlying.useCaseSensitiveFileNames; 359 }; 360 } 361 362 function recordReplay<T extends ts.AnyFunction>(original: T, underlying: any) { 363 function createWrapper(record: T, replay: T): T { 364 // eslint-disable-next-line local/only-arrow-functions 365 return (function () { 366 if (replayLog !== undefined) { 367 return replay.apply(undefined, arguments); 368 } 369 else if (recordLog !== undefined) { 370 return record.apply(undefined, arguments); 371 } 372 else { 373 return original.apply(underlying, arguments); 374 } 375 } as any); 376 } 377 return createWrapper; 378 } 379 380 function callAndRecord<T, U>(underlyingResult: T, logArray: U[], logEntry: U): T { 381 if (underlyingResult !== undefined) { 382 (logEntry as any).result = underlyingResult; 383 } 384 logArray.push(logEntry); 385 return underlyingResult; 386 } 387 388 function findResultByFields<T>(logArray: { result?: T }[], expectedFields: {}, defaultValue?: T): T | undefined { 389 const predicate = (entry: { result?: T }) => { 390 return Object.getOwnPropertyNames(expectedFields).every((name) => (entry as any)[name] === (expectedFields as any)[name]); 391 }; 392 const results = logArray.filter(entry => predicate(entry)); 393 if (results.length === 0) { 394 if (defaultValue !== undefined) { 395 return defaultValue; 396 } 397 else { 398 throw new Error("No matching result in log array for: " + JSON.stringify(expectedFields)); 399 } 400 } 401 return results[0].result; 402 } 403 404 function findFileByPath(expectedPath: string, throwFileNotFoundError: boolean): FileInformation | undefined { 405 const normalizedName = ts.normalizePath(expectedPath).toLowerCase(); 406 // Try to find the result through normal fileName 407 const result = replayFilesRead!.get(normalizedName); 408 if (result) { 409 return result.result; 410 } 411 412 // If we got here, we didn't find a match 413 if (throwFileNotFoundError) { 414 throw new Error("No matching result in log array for path: " + expectedPath); 415 } 416 else { 417 return undefined; 418 } 419 } 420 421 function noOpReplay(_name: string) { 422 // console.log("Swallowed write operation during replay: " + name); 423 } 424 425 export function wrapIO(underlying: Harness.IO): PlaybackIO { 426 const wrapper: PlaybackIO = {} as any; 427 initWrapper(wrapper, underlying); 428 429 wrapper.directoryName = notSupported; 430 wrapper.createDirectory = notSupported; 431 wrapper.directoryExists = notSupported; 432 wrapper.deleteFile = notSupported; 433 wrapper.listFiles = notSupported; 434 435 return wrapper; 436 437 function notSupported(): never { 438 throw new Error("NotSupported"); 439 } 440 } 441 442 export function wrapSystem(underlying: ts.System): PlaybackSystem { 443 const wrapper: PlaybackSystem = {} as any; 444 initWrapper(wrapper, underlying); 445 return wrapper; 446 } 447} 448 449// empty modules for the module migration script 450namespace ts.server { } // eslint-disable-line local/one-namespace-per-file 451namespace Harness { } // eslint-disable-line local/one-namespace-per-file 452