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