• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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