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