• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1namespace Playback {
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> = <Memoized<T>>((s: string) => {
97            if (lookup.hasOwnProperty(s)) return lookup[s];
98            return lookup[s] = func(s);
99        });
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    function initWrapper(wrapper: PlaybackSystem, underlying: ts.System): void;
209    function initWrapper(wrapper: PlaybackIO, underlying: Harness.IO): void;
210    function initWrapper(wrapper: PlaybackSystem | PlaybackIO, underlying: ts.System | Harness.IO): void {
211        ts.forEach(Object.keys(underlying), prop => {
212            (<any>wrapper)[prop] = (<any>underlying)[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 = (<ts.System>underlying).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
364    function recordReplay<T extends ts.AnyFunction>(original: T, underlying: any) {
365        function createWrapper(record: T, replay: T): T {
366            // eslint-disable-next-line only-arrow-functions
367            return <any>(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            });
378        }
379        return createWrapper;
380    }
381
382    function callAndRecord<T, U>(underlyingResult: T, logArray: U[], logEntry: U): T {
383        if (underlyingResult !== undefined) {
384            (<any>logEntry).result = underlyingResult;
385        }
386        logArray.push(logEntry);
387        return underlyingResult;
388    }
389
390    function findResultByFields<T>(logArray: { result?: T }[], expectedFields: {}, defaultValue?: T): T | undefined {
391        const predicate = (entry: { result?: T }) => {
392            return Object.getOwnPropertyNames(expectedFields).every((name) => (<any>entry)[name] === (<any>expectedFields)[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
406    function 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
423    function noOpReplay(_name: string) {
424        // console.log("Swallowed write operation during replay: " + name);
425    }
426
427    export function wrapIO(underlying: Harness.IO): PlaybackIO {
428        const wrapper: PlaybackIO = <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
444    export function wrapSystem(underlying: ts.System): PlaybackSystem {
445        const wrapper: PlaybackSystem = <any>{};
446        initWrapper(wrapper, underlying);
447        return wrapper;
448    }
449}
450