refactor(ivy): implement a virtual file-system layer in ngtsc + ngcc (#30921)
To improve cross platform support, all file access (and path manipulation) is now done through a well known interface (`FileSystem`). For testing a number of `MockFileSystem` implementations are provided. These provide an in-memory file-system which emulates operating systems like OS/X, Unix and Windows. The current file system is always available via the static method, `FileSystem.getFileSystem()`. This is also used by a number of static methods on `AbsoluteFsPath` and `PathSegment`, to avoid having to pass `FileSystem` objects around all the time. The result of this is that one must be careful to ensure that the file-system has been initialized before using any of these static methods. To prevent this happening accidentally the current file system always starts out as an instance of `InvalidFileSystem`, which will throw an error if any of its methods are called. You can set the current file-system by calling `FileSystem.setFileSystem()`. During testing you can call the helper function `initMockFileSystem(os)` which takes a string name of the OS to emulate, and will also monkey-patch aspects of the TypeScript library to ensure that TS is also using the current file-system. Finally there is the `NgtscCompilerHost` to be used for any TypeScript compilation, which uses a given file-system. All tests that interact with the file-system should be tested against each of the mock file-systems. A series of helpers have been provided to support such tests: * `runInEachFileSystem()` - wrap your tests in this helper to run all the wrapped tests in each of the mock file-systems. * `addTestFilesToFileSystem()` - use this to add files and their contents to the mock file system for testing. * `loadTestFilesFromDisk()` - use this to load a mirror image of files on disk into the in-memory mock file-system. * `loadFakeCore()` - use this to load a fake version of `@angular/core` into the mock file-system. All ngcc and ngtsc source and tests now use this virtual file-system setup. PR Close #30921
This commit is contained in:

committed by
Kara Erickson

parent
1e7e065423
commit
7186f9c016
@ -10,6 +10,7 @@ ts_library(
|
||||
]),
|
||||
deps = [
|
||||
"//packages:types",
|
||||
"//packages/compiler-cli/src/ngtsc/file_system",
|
||||
"@npm//typescript",
|
||||
],
|
||||
)
|
||||
|
@ -1,157 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright Google Inc. All Rights Reserved.
|
||||
*
|
||||
* Use of this source code is governed by an MIT-style license that can be
|
||||
* found in the LICENSE file at https://angular.io/license
|
||||
*/
|
||||
|
||||
///<reference types="jasmine"/>
|
||||
|
||||
import * as path from 'path';
|
||||
import * as ts from 'typescript';
|
||||
|
||||
export function makeProgram(
|
||||
files: {name: string, contents: string, isRoot?: boolean}[], options?: ts.CompilerOptions,
|
||||
host: ts.CompilerHost = new InMemoryHost(), checkForErrors: boolean = true):
|
||||
{program: ts.Program, host: ts.CompilerHost, options: ts.CompilerOptions} {
|
||||
files.forEach(file => host.writeFile(file.name, file.contents, false, undefined, []));
|
||||
|
||||
const rootNames =
|
||||
files.filter(file => file.isRoot !== false).map(file => host.getCanonicalFileName(file.name));
|
||||
const compilerOptions = {
|
||||
noLib: true,
|
||||
experimentalDecorators: true,
|
||||
moduleResolution: ts.ModuleResolutionKind.NodeJs, ...options
|
||||
};
|
||||
const program = ts.createProgram(rootNames, compilerOptions, host);
|
||||
if (checkForErrors) {
|
||||
const diags = [...program.getSyntacticDiagnostics(), ...program.getSemanticDiagnostics()];
|
||||
if (diags.length > 0) {
|
||||
const errors = diags.map(diagnostic => {
|
||||
let message = ts.flattenDiagnosticMessageText(diagnostic.messageText, '\n');
|
||||
if (diagnostic.file) {
|
||||
const {line, character} =
|
||||
diagnostic.file.getLineAndCharacterOfPosition(diagnostic.start !);
|
||||
message = `${diagnostic.file.fileName} (${line + 1},${character + 1}): ${message}`;
|
||||
}
|
||||
return `Error: ${message}`;
|
||||
});
|
||||
throw new Error(`Typescript diagnostics failed! ${errors.join(', ')}`);
|
||||
}
|
||||
}
|
||||
return {program, host, options: compilerOptions};
|
||||
}
|
||||
|
||||
export class InMemoryHost implements ts.CompilerHost {
|
||||
private fileSystem = new Map<string, string>();
|
||||
|
||||
getSourceFile(
|
||||
fileName: string, languageVersion: ts.ScriptTarget,
|
||||
onError?: ((message: string) => void)|undefined,
|
||||
shouldCreateNewSourceFile?: boolean|undefined): ts.SourceFile|undefined {
|
||||
const contents = this.fileSystem.get(this.getCanonicalFileName(fileName));
|
||||
if (contents === undefined) {
|
||||
onError && onError(`File does not exist: ${this.getCanonicalFileName(fileName)})`);
|
||||
return undefined;
|
||||
}
|
||||
return ts.createSourceFile(fileName, contents, languageVersion);
|
||||
}
|
||||
|
||||
getDefaultLibFileName(options: ts.CompilerOptions): string { return '/lib.d.ts'; }
|
||||
|
||||
writeFile(
|
||||
fileName: string, data: string, writeByteOrderMark?: boolean,
|
||||
onError?: ((message: string) => void)|undefined,
|
||||
sourceFiles?: ReadonlyArray<ts.SourceFile>): void {
|
||||
this.fileSystem.set(this.getCanonicalFileName(fileName), data);
|
||||
}
|
||||
|
||||
getCurrentDirectory(): string { return '/'; }
|
||||
|
||||
getDirectories(dir: string): string[] {
|
||||
const fullDir = this.getCanonicalFileName(dir) + '/';
|
||||
const dirSet = new Set(Array
|
||||
// Look at all paths known to the host.
|
||||
.from(this.fileSystem.keys())
|
||||
// Filter out those that aren't under the requested directory.
|
||||
.filter(candidate => candidate.startsWith(fullDir))
|
||||
// Relativize the rest by the requested directory.
|
||||
.map(candidate => candidate.substr(fullDir.length))
|
||||
// What's left are dir/.../file.txt entries, and file.txt entries.
|
||||
// Get the dirname, which
|
||||
// yields '.' for the latter and dir/... for the former.
|
||||
.map(candidate => path.dirname(candidate))
|
||||
// Filter out the '.' entries, which were files.
|
||||
.filter(candidate => candidate !== '.')
|
||||
// Finally, split on / and grab the first entry.
|
||||
.map(candidate => candidate.split('/', 1)[0]));
|
||||
|
||||
// Get the resulting values out of the Set.
|
||||
return Array.from(dirSet);
|
||||
}
|
||||
|
||||
getCanonicalFileName(fileName: string): string {
|
||||
return path.posix.normalize(`${this.getCurrentDirectory()}/${fileName}`);
|
||||
}
|
||||
|
||||
useCaseSensitiveFileNames(): boolean { return true; }
|
||||
|
||||
getNewLine(): string { return '\n'; }
|
||||
|
||||
fileExists(fileName: string): boolean { return this.fileSystem.has(fileName); }
|
||||
|
||||
readFile(fileName: string): string|undefined { return this.fileSystem.get(fileName); }
|
||||
}
|
||||
|
||||
function bindingNameEquals(node: ts.BindingName, name: string): boolean {
|
||||
if (ts.isIdentifier(node)) {
|
||||
return node.text === name;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function getDeclaration<T extends ts.Declaration>(
|
||||
program: ts.Program, fileName: string, name: string, assert: (value: any) => value is T): T {
|
||||
const sf = program.getSourceFile(fileName);
|
||||
if (!sf) {
|
||||
throw new Error(`No such file: ${fileName}`);
|
||||
}
|
||||
const chosenDecl = walkForDeclaration(sf);
|
||||
|
||||
if (chosenDecl === null) {
|
||||
throw new Error(`No such symbol: ${name} in ${fileName}`);
|
||||
}
|
||||
if (!assert(chosenDecl)) {
|
||||
throw new Error(`Symbol ${name} from ${fileName} is a ${ts.SyntaxKind[chosenDecl.kind]}`);
|
||||
}
|
||||
return chosenDecl;
|
||||
|
||||
// We walk the AST tree looking for a declaration that matches
|
||||
function walkForDeclaration(rootNode: ts.Node): ts.Declaration|null {
|
||||
let chosenDecl: ts.Declaration|null = null;
|
||||
rootNode.forEachChild(node => {
|
||||
if (chosenDecl !== null) {
|
||||
return;
|
||||
}
|
||||
if (ts.isVariableStatement(node)) {
|
||||
node.declarationList.declarations.forEach(decl => {
|
||||
if (bindingNameEquals(decl.name, name)) {
|
||||
chosenDecl = decl;
|
||||
}
|
||||
});
|
||||
} else if (ts.isClassDeclaration(node) || ts.isFunctionDeclaration(node)) {
|
||||
if (node.name !== undefined && node.name.text === name) {
|
||||
chosenDecl = node;
|
||||
}
|
||||
} else if (
|
||||
ts.isImportDeclaration(node) && node.importClause !== undefined &&
|
||||
node.importClause.name !== undefined && node.importClause.name.text === name) {
|
||||
chosenDecl = node.importClause;
|
||||
} else {
|
||||
chosenDecl = walkForDeclaration(node);
|
||||
}
|
||||
});
|
||||
return chosenDecl;
|
||||
}
|
||||
}
|
8
packages/compiler-cli/src/ngtsc/testing/index.ts
Normal file
8
packages/compiler-cli/src/ngtsc/testing/index.ts
Normal file
@ -0,0 +1,8 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright Google Inc. All Rights Reserved.
|
||||
*
|
||||
* Use of this source code is governed by an MIT-style license that can be
|
||||
* found in the LICENSE file at https://angular.io/license
|
||||
*/
|
||||
export {getDeclaration, makeProgram} from './src/utils';
|
100
packages/compiler-cli/src/ngtsc/testing/src/utils.ts
Normal file
100
packages/compiler-cli/src/ngtsc/testing/src/utils.ts
Normal file
@ -0,0 +1,100 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright Google Inc. All Rights Reserved.
|
||||
*
|
||||
* Use of this source code is governed by an MIT-style license that can be
|
||||
* found in the LICENSE file at https://angular.io/license
|
||||
*/
|
||||
|
||||
///<reference types="jasmine"/>
|
||||
|
||||
import * as ts from 'typescript';
|
||||
|
||||
import {AbsoluteFsPath, NgtscCompilerHost, dirname, getFileSystem, getSourceFileOrError} from '../../file_system';
|
||||
|
||||
export function makeProgram(
|
||||
files: {name: AbsoluteFsPath, contents: string, isRoot?: boolean}[],
|
||||
options?: ts.CompilerOptions, host?: ts.CompilerHost, checkForErrors: boolean = true):
|
||||
{program: ts.Program, host: ts.CompilerHost, options: ts.CompilerOptions} {
|
||||
const fs = getFileSystem();
|
||||
files.forEach(file => {
|
||||
fs.ensureDir(dirname(file.name));
|
||||
fs.writeFile(file.name, file.contents);
|
||||
});
|
||||
|
||||
const compilerOptions = {
|
||||
noLib: true,
|
||||
experimentalDecorators: true,
|
||||
moduleResolution: ts.ModuleResolutionKind.NodeJs, ...options
|
||||
};
|
||||
const compilerHost = new NgtscCompilerHost(fs, compilerOptions);
|
||||
const rootNames = files.filter(file => file.isRoot !== false)
|
||||
.map(file => compilerHost.getCanonicalFileName(file.name));
|
||||
const program = ts.createProgram(rootNames, compilerOptions, compilerHost);
|
||||
if (checkForErrors) {
|
||||
const diags = [...program.getSyntacticDiagnostics(), ...program.getSemanticDiagnostics()];
|
||||
if (diags.length > 0) {
|
||||
const errors = diags.map(diagnostic => {
|
||||
let message = ts.flattenDiagnosticMessageText(diagnostic.messageText, '\n');
|
||||
if (diagnostic.file) {
|
||||
const {line, character} =
|
||||
diagnostic.file.getLineAndCharacterOfPosition(diagnostic.start !);
|
||||
message = `${diagnostic.file.fileName} (${line + 1},${character + 1}): ${message}`;
|
||||
}
|
||||
return `Error: ${message}`;
|
||||
});
|
||||
throw new Error(`Typescript diagnostics failed! ${errors.join(', ')}`);
|
||||
}
|
||||
}
|
||||
return {program, host: compilerHost, options: compilerOptions};
|
||||
}
|
||||
|
||||
export function getDeclaration<T extends ts.Declaration>(
|
||||
program: ts.Program, fileName: AbsoluteFsPath, name: string,
|
||||
assert: (value: any) => value is T): T {
|
||||
const sf = getSourceFileOrError(program, fileName);
|
||||
const chosenDecl = walkForDeclaration(sf);
|
||||
|
||||
if (chosenDecl === null) {
|
||||
throw new Error(`No such symbol: ${name} in ${fileName}`);
|
||||
}
|
||||
if (!assert(chosenDecl)) {
|
||||
throw new Error(`Symbol ${name} from ${fileName} is a ${ts.SyntaxKind[chosenDecl.kind]}`);
|
||||
}
|
||||
return chosenDecl;
|
||||
|
||||
// We walk the AST tree looking for a declaration that matches
|
||||
function walkForDeclaration(rootNode: ts.Node): ts.Declaration|null {
|
||||
let chosenDecl: ts.Declaration|null = null;
|
||||
rootNode.forEachChild(node => {
|
||||
if (chosenDecl !== null) {
|
||||
return;
|
||||
}
|
||||
if (ts.isVariableStatement(node)) {
|
||||
node.declarationList.declarations.forEach(decl => {
|
||||
if (bindingNameEquals(decl.name, name)) {
|
||||
chosenDecl = decl;
|
||||
}
|
||||
});
|
||||
} else if (ts.isClassDeclaration(node) || ts.isFunctionDeclaration(node)) {
|
||||
if (node.name !== undefined && node.name.text === name) {
|
||||
chosenDecl = node;
|
||||
}
|
||||
} else if (
|
||||
ts.isImportDeclaration(node) && node.importClause !== undefined &&
|
||||
node.importClause.name !== undefined && node.importClause.name.text === name) {
|
||||
chosenDecl = node.importClause;
|
||||
} else {
|
||||
chosenDecl = walkForDeclaration(node);
|
||||
}
|
||||
});
|
||||
return chosenDecl;
|
||||
}
|
||||
}
|
||||
|
||||
function bindingNameEquals(node: ts.BindingName, name: string): boolean {
|
||||
if (ts.isIdentifier(node)) {
|
||||
return node.text === name;
|
||||
}
|
||||
return false;
|
||||
}
|
Reference in New Issue
Block a user