chore(build): Added tests for metadata extractor

Adds unit test to metadata extractor classes
Fixes issues found while testing
This commit is contained in:
Chuck Jazdzewski 2016-03-10 13:39:19 -08:00
parent ae876d1317
commit 3f57fa6e0e
6 changed files with 531 additions and 16 deletions

View File

@ -0,0 +1,128 @@
var mockfs = require('mock-fs');
import * as ts from 'typescript';
import * as fs from 'fs';
import {MockHost, expectNoDiagnostics, findVar} from './typescript.mock';
import {Evaluator} from './evaluator';
import {Symbols} from './symbols';
describe('Evaluator', () => {
// Read the lib.d.ts before mocking fs.
let libTs: string = fs.readFileSync(ts.getDefaultLibFilePath({}), 'utf8');
beforeEach(() => files['lib.d.ts'] = libTs);
beforeEach(() => mockfs(files));
afterEach(() => mockfs.restore());
let host: ts.LanguageServiceHost;
let service: ts.LanguageService;
let program: ts.Program;
let typeChecker: ts.TypeChecker;
let symbols: Symbols;
let evaluator: Evaluator;
beforeEach(() => {
host = new MockHost(['expressions.ts'], /*currentDirectory*/ undefined, 'lib.d.ts');
service = ts.createLanguageService(host);
program = service.getProgram();
typeChecker = program.getTypeChecker();
symbols = new Symbols();
evaluator = new Evaluator(service, typeChecker, symbols, f => f);
});
it('should not have typescript errors in test data', () => {
expectNoDiagnostics(service.getCompilerOptionsDiagnostics());
for (const sourceFile of program.getSourceFiles()) {
expectNoDiagnostics(service.getSyntacticDiagnostics(sourceFile.fileName));
}
});
it('should be able to fold literal expressions', () => {
var consts = program.getSourceFile('consts.ts');
expect(evaluator.isFoldable(findVar(consts, 'someName').initializer)).toBeTruthy();
expect(evaluator.isFoldable(findVar(consts, 'someBool').initializer)).toBeTruthy();
expect(evaluator.isFoldable(findVar(consts, 'one').initializer)).toBeTruthy();
expect(evaluator.isFoldable(findVar(consts, 'two').initializer)).toBeTruthy();
});
it('should be able to fold expressions with foldable references', () => {
var expressions = program.getSourceFile('expressions.ts');
expect(evaluator.isFoldable(findVar(expressions, 'three').initializer)).toBeTruthy();
expect(evaluator.isFoldable(findVar(expressions, 'four').initializer)).toBeTruthy();
expect(evaluator.isFoldable(findVar(expressions, 'obj').initializer)).toBeTruthy();
expect(evaluator.isFoldable(findVar(expressions, 'arr').initializer)).toBeTruthy();
});
it('should be able to evaluate literal expressions', () => {
var consts = program.getSourceFile('consts.ts');
expect(evaluator.evaluateNode(findVar(consts, 'someName').initializer)).toBe('some-name');
expect(evaluator.evaluateNode(findVar(consts, 'someBool').initializer)).toBe(true);
expect(evaluator.evaluateNode(findVar(consts, 'one').initializer)).toBe(1);
expect(evaluator.evaluateNode(findVar(consts, 'two').initializer)).toBe(2);
});
it('should be able to evaluate expressions', () => {
var expressions = program.getSourceFile('expressions.ts');
expect(evaluator.evaluateNode(findVar(expressions, 'three').initializer)).toBe(3);
expect(evaluator.evaluateNode(findVar(expressions, 'four').initializer)).toBe(4);
expect(evaluator.evaluateNode(findVar(expressions, 'obj').initializer))
.toEqual({one: 1, two: 2, three: 3, four: 4});
expect(evaluator.evaluateNode(findVar(expressions, 'arr').initializer)).toEqual([1, 2, 3, 4]);
expect(evaluator.evaluateNode(findVar(expressions, 'bTrue').initializer)).toEqual(true);
expect(evaluator.evaluateNode(findVar(expressions, 'bFalse').initializer)).toEqual(false);
expect(evaluator.evaluateNode(findVar(expressions, 'bAnd').initializer)).toEqual(true);
expect(evaluator.evaluateNode(findVar(expressions, 'bOr').initializer)).toEqual(true);
expect(evaluator.evaluateNode(findVar(expressions, 'nDiv').initializer)).toEqual(2);
expect(evaluator.evaluateNode(findVar(expressions, 'nMod').initializer)).toEqual(1);
});
it('should report recursive references as symbolic', () => {
var expressions = program.getSourceFile('expressions.ts');
expect(evaluator.evaluateNode(findVar(expressions, 'recursiveA').initializer))
.toEqual({__symbolic: "reference", name: "recursiveB", module: "expressions.ts"});
expect(evaluator.evaluateNode(findVar(expressions, 'recursiveB').initializer))
.toEqual({__symbolic: "reference", name: "recursiveA", module: "expressions.ts"});
});
});
const files = {
'directives.ts': `
export function Pipe(options: { name?: string, pure?: boolean}) {
return function(fn: Function) { }
}
`,
'consts.ts': `
export var someName = 'some-name';
export var someBool = true;
export var one = 1;
export var two = 2;
`,
'expressions.ts': `
import {someName, someBool, one, two} from './consts';
export var three = one + two;
export var four = two * two;
export var obj = { one: one, two: two, three: three, four: four };
export var arr = [one, two, three, four];
export var bTrue = someBool;
export var bFalse = !someBool;
export var bAnd = someBool && someBool;
export var bOr = someBool || someBool;
export var nDiv = four / two;
export var nMod = (four + one) % two;
export var recursiveA = recursiveB;
export var recursiveB = recursiveA;
`,
'A.ts': `
import {Pipe} from './directives';
@Pipe({name: 'A', pure: false})
export class A {}`,
'B.ts': `
import {Pipe} from './directives';
import {someName, someBool} from './consts';
@Pipe({name: someName, pure: someBool})
export class B {}`
}

View File

@ -1,6 +1,18 @@
import * as ts from 'typescript';
import {Symbols} from './symbols';
// TOOD: Remove when tools directory is upgraded to support es6 target
interface Map<K, V> {
has(k: K): boolean;
set(k: K, v: V): void;
get(k: K): V;
delete (k: K): void;
}
interface MapConstructor {
new<K, V>(): Map<K, V>;
}
declare var Map: MapConstructor;
function isMethodCallOf(callExpression: ts.CallExpression, memberName: string): boolean {
const expression = callExpression.expression;
if (expression.kind === ts.SyntaxKind.PropertyAccessExpression) {
@ -105,25 +117,30 @@ export class Evaluator {
* - An identifier is foldable if a value can be found for its symbol is in the evaluator symbol
* table.
*/
public isFoldable(node: ts.Node) {
public isFoldable(node: ts.Node): boolean {
return this.isFoldableWorker(node, new Map<ts.Node, boolean>());
}
private isFoldableWorker(node: ts.Node, folding: Map<ts.Node, boolean>): boolean {
if (node) {
switch (node.kind) {
case ts.SyntaxKind.ObjectLiteralExpression:
return everyNodeChild(node, child => {
if (child.kind === ts.SyntaxKind.PropertyAssignment) {
const propertyAssignment = <ts.PropertyAssignment>child;
return this.isFoldable(propertyAssignment.initializer)
return this.isFoldableWorker(propertyAssignment.initializer, folding)
}
return false;
});
case ts.SyntaxKind.ArrayLiteralExpression:
return everyNodeChild(node, child => this.isFoldable(child));
return everyNodeChild(node, child => this.isFoldableWorker(child, folding));
case ts.SyntaxKind.CallExpression:
const callExpression = <ts.CallExpression>node;
// We can fold a <array>.concat(<v>).
if (isMethodCallOf(callExpression, "concat") && callExpression.arguments.length === 1) {
const arrayNode = (<ts.PropertyAccessExpression>callExpression.expression).expression;
if (this.isFoldable(arrayNode) && this.isFoldable(callExpression.arguments[0])) {
if (this.isFoldableWorker(arrayNode, folding) &&
this.isFoldableWorker(callExpression.arguments[0], folding)) {
// It needs to be an array.
const arrayValue = this.evaluateNode(arrayNode);
if (arrayValue && Array.isArray(arrayValue)) {
@ -133,7 +150,7 @@ export class Evaluator {
}
// We can fold a call to CONST_EXPR
if (isCallOf(callExpression, "CONST_EXPR") && callExpression.arguments.length === 1)
return this.isFoldable(callExpression.arguments[0]);
return this.isFoldableWorker(callExpression.arguments[0], folding);
return false;
case ts.SyntaxKind.NoSubstitutionTemplateLiteral:
case ts.SyntaxKind.StringLiteral:
@ -142,6 +159,9 @@ export class Evaluator {
case ts.SyntaxKind.TrueKeyword:
case ts.SyntaxKind.FalseKeyword:
return true;
case ts.SyntaxKind.ParenthesizedExpression:
const parenthesizedExpression = <ts.ParenthesizedExpression>node;
return this.isFoldableWorker(parenthesizedExpression.expression, folding);
case ts.SyntaxKind.BinaryExpression:
const binaryExpression = <ts.BinaryExpression>node;
switch (binaryExpression.operatorToken.kind) {
@ -152,19 +172,37 @@ export class Evaluator {
case ts.SyntaxKind.PercentToken:
case ts.SyntaxKind.AmpersandAmpersandToken:
case ts.SyntaxKind.BarBarToken:
return this.isFoldable(binaryExpression.left) &&
this.isFoldable(binaryExpression.right);
return this.isFoldableWorker(binaryExpression.left, folding) &&
this.isFoldableWorker(binaryExpression.right, folding);
}
case ts.SyntaxKind.PropertyAccessExpression:
const propertyAccessExpression = <ts.PropertyAccessExpression>node;
return this.isFoldable(propertyAccessExpression.expression);
return this.isFoldableWorker(propertyAccessExpression.expression, folding);
case ts.SyntaxKind.ElementAccessExpression:
const elementAccessExpression = <ts.ElementAccessExpression>node;
return this.isFoldable(elementAccessExpression.expression) &&
this.isFoldable(elementAccessExpression.argumentExpression);
return this.isFoldableWorker(elementAccessExpression.expression, folding) &&
this.isFoldableWorker(elementAccessExpression.argumentExpression, folding);
case ts.SyntaxKind.Identifier:
const symbol = this.typeChecker.getSymbolAtLocation(node);
let symbol = this.typeChecker.getSymbolAtLocation(node);
if (symbol.flags & ts.SymbolFlags.Alias) {
symbol = this.typeChecker.getAliasedSymbol(symbol);
}
if (this.symbols.has(symbol)) return true;
// If this is a reference to a foldable variable then it is foldable too.
const variableDeclaration = <ts.VariableDeclaration>(
symbol.declarations && symbol.declarations.length && symbol.declarations[0]);
if (variableDeclaration.kind === ts.SyntaxKind.VariableDeclaration) {
const initializer = variableDeclaration.initializer;
if (folding.has(initializer)) {
// A recursive reference is not foldable.
return false;
}
folding.set(initializer, true);
const result = this.isFoldableWorker(initializer, folding);
folding.delete(initializer);
return result;
}
break;
}
}
@ -252,8 +290,17 @@ export class Evaluator {
break;
}
case ts.SyntaxKind.Identifier:
const symbol = this.typeChecker.getSymbolAtLocation(node);
let symbol = this.typeChecker.getSymbolAtLocation(node);
if (symbol.flags & ts.SymbolFlags.Alias) {
symbol = this.typeChecker.getAliasedSymbol(symbol);
}
if (this.symbols.has(symbol)) return this.symbols.get(symbol);
if (this.isFoldable(node)) {
// isFoldable implies, in this context, symbol declaration is a VariableDeclaration
const variableDeclaration = <ts.VariableDeclaration>(
symbol.declarations && symbol.declarations.length && symbol.declarations[0]);
return this.evaluateNode(variableDeclaration.initializer);
}
return this.nodeSymbolReference(node);
case ts.SyntaxKind.NoSubstitutionTemplateLiteral:
return (<ts.LiteralExpression>node).text;
@ -267,7 +314,42 @@ export class Evaluator {
return true;
case ts.SyntaxKind.FalseKeyword:
return false;
case ts.SyntaxKind.ParenthesizedExpression:
const parenthesizedExpression = <ts.ParenthesizedExpression>node;
return this.evaluateNode(parenthesizedExpression.expression);
case ts.SyntaxKind.PrefixUnaryExpression:
const prefixUnaryExpression = <ts.PrefixUnaryExpression>node;
const operand = this.evaluateNode(prefixUnaryExpression.operand);
if (isDefined(operand) && isPrimitive(operand)) {
switch (prefixUnaryExpression.operator) {
case ts.SyntaxKind.PlusToken:
return +operand;
case ts.SyntaxKind.MinusToken:
return -operand;
case ts.SyntaxKind.TildeToken:
return ~operand;
case ts.SyntaxKind.ExclamationToken:
return !operand;
}
}
let operatorText: string;
switch (prefixUnaryExpression.operator) {
case ts.SyntaxKind.PlusToken:
operatorText = '+';
break;
case ts.SyntaxKind.MinusToken:
operatorText = '-';
break;
case ts.SyntaxKind.TildeToken:
operatorText = '~';
break;
case ts.SyntaxKind.ExclamationToken:
operatorText = '!';
break;
default:
return undefined;
}
return {__symbolic: "pre", operator: operatorText, operand: operand };
case ts.SyntaxKind.BinaryExpression:
const binaryExpression = <ts.BinaryExpression>node;
const left = this.evaluateNode(binaryExpression.left);

View File

@ -0,0 +1,138 @@
var mockfs = require('mock-fs');
import * as ts from 'typescript';
import * as fs from 'fs';
import {MockHost, expectNoDiagnostics, findClass} from './typescript.mock';
import {MetadataExtractor} from './extractor';
describe('MetadataExtractor', () => {
// Read the lib.d.ts before mocking fs.
let libTs: string = fs.readFileSync(ts.getDefaultLibFilePath({}), 'utf8');
beforeEach(() => files['lib.d.ts'] = libTs);
beforeEach(() => mockfs(files));
afterEach(() => mockfs.restore());
let host: ts.LanguageServiceHost;
let service: ts.LanguageService;
let program: ts.Program;
let typeChecker: ts.TypeChecker;
let extractor: MetadataExtractor;
beforeEach(() => {
host = new MockHost(['A.ts', 'B.ts', 'C.ts'], /*currentDirectory*/ undefined, 'lib.d.ts');
service = ts.createLanguageService(host);
program = service.getProgram();
typeChecker = program.getTypeChecker();
extractor = new MetadataExtractor(service);
});
it('should not have typescript errors in test data', () => {
expectNoDiagnostics(service.getCompilerOptionsDiagnostics());
for (const sourceFile of program.getSourceFiles()) {
expectNoDiagnostics(service.getSyntacticDiagnostics(sourceFile.fileName));
}
});
it('should be able to extract metadata when defined by literals', () => {
const sourceFile = program.getSourceFile('A.ts');
const metadata = extractor.getMetadata(sourceFile, typeChecker);
expect(metadata).toEqual({
__symbolic: 'module',
module: './A',
metadata: {
A: {
__symbolic: 'class',
decorators: [
{
__symbolic: 'call',
expression: {__symbolic: 'reference', name: 'Pipe', module: './directives'},
arguments: [{name: 'A', pure: false}]
}
]
}
}
});
});
it('should be able to extract metadata from metadata defined using vars', () => {
const sourceFile = program.getSourceFile('B.ts');
const metadata = extractor.getMetadata(sourceFile, typeChecker);
expect(metadata).toEqual({
__symbolic: 'module',
module: './B',
metadata: {
B: {
__symbolic: 'class',
decorators: [
{
__symbolic: 'call',
expression: {__symbolic: 'reference', name: 'Pipe', module: './directives'},
arguments: [{name: 'some-name', pure: true}]
}
]
}
}
});
});
it('souce be able to extract metadata that uses external references', () => {
const sourceFile = program.getSourceFile('C.ts');
const metadata = extractor.getMetadata(sourceFile, typeChecker);
expect(metadata).toEqual({
__symbolic: 'module',
module: './C',
metadata: {
B: {
__symbolic: 'class',
decorators: [
{
__symbolic: 'call',
expression: {__symbolic: 'reference', name: 'Pipe', module: './directives'},
arguments: [
{
name: {__symbolic: "reference", module: "./external", name: "externalName"},
pure:
{__symbolic: "reference", module: "./external", name: "externalBool"}
}
]
}
]
}
}
});
});
});
const files = {
'directives.ts': `
export function Pipe(options: { name?: string, pure?: boolean}) {
return function(fn: Function) { }
}
`,
'consts.ts': `
export var someName = 'some-name';
export var someBool = true;
`,
'external.d.ts': `
export const externalName: string;
export const externalBool: boolean;
`,
'A.ts': `
import {Pipe} from './directives';
@Pipe({name: 'A', pure: false})
export class A {}`,
'B.ts': `
import {Pipe} from './directives';
import {someName, someBool} from './consts';
@Pipe({name: someName, pure: someBool})
export class B {}`,
'C.ts': `
import {Pipe} from './directives';
import {externalName, externalBool} from './external';
@Pipe({name: externalName, pure: externalBool})
export class B {}`
}

View File

@ -0,0 +1,32 @@
/// <reference path="../typings/node/node.d.ts" />
/// <reference path="../typings/jasmine/jasmine.d.ts" />
import * as ts from 'typescript';
import {Symbols} from './symbols';
import {MockSymbol, MockVariableDeclaration} from './typescript.mock';
describe('Symbols', () => {
let symbols: Symbols;
const someValue = 'some-value';
const someSymbol = MockSymbol.of('some-symbol');
const aliasSymbol = new MockSymbol('some-symbol', someSymbol.getDeclarations()[0]);
const missingSymbol = MockSymbol.of('some-other-symbol');
beforeEach(() => symbols = new Symbols());
it('should be able to add a symbol', () => symbols.set(someSymbol, someValue));
beforeEach(() => symbols.set(someSymbol, someValue));
it('should be able to `has` a symbol', () => expect(symbols.has(someSymbol)).toBeTruthy());
it('should be able to `get` a symbol value',
() => expect(symbols.get(someSymbol)).toBe(someValue));
it('should be able to `has` an alias symbol',
() => expect(symbols.has(aliasSymbol)).toBeTruthy());
it('should be able to `get` a symbol value',
() => expect(symbols.get(aliasSymbol)).toBe(someValue));
it('should be able to determine symbol is missing',
() => expect(symbols.has(missingSymbol)).toBeFalsy());
it('should return undefined from `get` for a missing symbol',
() => expect(symbols.get(missingSymbol)).toBeUndefined());
});

View File

@ -24,11 +24,11 @@ var a: Array<number>;
export class Symbols {
private map = new Map<ts.Node, any>();
public has(symbol: ts.Symbol): boolean { return this.map.has(symbol.declarations[0]); }
public has(symbol: ts.Symbol): boolean { return this.map.has(symbol.getDeclarations()[0]); }
public set(symbol: ts.Symbol, value): void { this.map.set(symbol.declarations[0], value); }
public set(symbol: ts.Symbol, value): void { this.map.set(symbol.getDeclarations()[0], value); }
public get(symbol: ts.Symbol): any { return this.map.get(symbol.declarations[0]); }
public get(symbol: ts.Symbol): any { return this.map.get(symbol.getDeclarations()[0]); }
static empty: Symbols = new Symbols();
}

View File

@ -0,0 +1,135 @@
import * as ts from 'typescript';
import * as fs from 'fs';
/**
* A mock language service host that assumes mock-fs is used for the file system.
*/
export class MockHost implements ts.LanguageServiceHost {
constructor(private fileNames: string[], private currentDirectory: string = process.cwd(),
private libName?: string) {}
getCompilationSettings(): ts.CompilerOptions {
return {
experimentalDecorators: true,
modules: ts.ModuleKind.CommonJS,
target: ts.ScriptTarget.ES5
};
}
getScriptFileNames(): string[] { return this.fileNames; }
getScriptVersion(fileName: string): string { return "1"; }
getScriptSnapshot(fileName: string): ts.IScriptSnapshot {
if (fs.existsSync(fileName)) {
return ts.ScriptSnapshot.fromString(fs.readFileSync(fileName, 'utf8'))
}
}
getCurrentDirectory(): string { return this.currentDirectory; }
getDefaultLibFileName(options: ts.CompilerOptions): string {
return this.libName || ts.getDefaultLibFilePath(options);
}
}
export class MockNode implements ts.Node {
constructor(public kind: ts.SyntaxKind = ts.SyntaxKind.Identifier, public flags: ts.NodeFlags = 0,
public pos: number = 0, public end: number = 0) {}
getSourceFile(): ts.SourceFile { return null; }
getChildCount(sourceFile?: ts.SourceFile): number { return 0 }
getChildAt(index: number, sourceFile?: ts.SourceFile): ts.Node { return null; }
getChildren(sourceFile?: ts.SourceFile): ts.Node[] { return []; }
getStart(sourceFile?: ts.SourceFile): number { return 0; }
getFullStart(): number { return 0; }
getEnd(): number { return 0; }
getWidth(sourceFile?: ts.SourceFile): number { return 0; }
getFullWidth(): number { return 0; }
getLeadingTriviaWidth(sourceFile?: ts.SourceFile): number { return 0; }
getFullText(sourceFile?: ts.SourceFile): string { return ''; }
getText(sourceFile?: ts.SourceFile): string { return ''; }
getFirstToken(sourceFile?: ts.SourceFile): ts.Node { return null; }
getLastToken(sourceFile?: ts.SourceFile): ts.Node { return null; }
}
export class MockIdentifier extends MockNode implements ts.Identifier {
public text: string;
public _primaryExpressionBrand: any;
public _memberExpressionBrand: any;
public _leftHandSideExpressionBrand: any;
public _incrementExpressionBrand: any;
public _unaryExpressionBrand: any;
public _expressionBrand: any;
constructor(public name: string, kind: ts.SyntaxKind = ts.SyntaxKind.Identifier,
flags: ts.NodeFlags = 0, pos: number = 0, end: number = 0) {
super(kind, flags, pos, end);
this.text = name;
}
}
export class MockVariableDeclaration extends MockNode implements ts.VariableDeclaration {
public _declarationBrand: any;
constructor(public name: ts.Identifier, kind: ts.SyntaxKind = ts.SyntaxKind.VariableDeclaration,
flags: ts.NodeFlags = 0, pos: number = 0, end: number = 0) {
super(kind, flags, pos, end);
}
static of(name: string): MockVariableDeclaration {
return new MockVariableDeclaration(new MockIdentifier(name));
}
}
export class MockSymbol implements ts.Symbol {
constructor(public name: string, private node: ts.Declaration = MockVariableDeclaration.of(name),
public flags: ts.SymbolFlags = 0) {}
getFlags(): ts.SymbolFlags { return this.flags; }
getName(): string { return this.name; }
getDeclarations(): ts.Declaration[] { return [this.node]; }
getDocumentationComment(): ts.SymbolDisplayPart[] { return []; }
static of(name: string): MockSymbol { return new MockSymbol(name); }
}
export function expectNoDiagnostics(diagnostics: ts.Diagnostic[]) {
for (const diagnostic of diagnostics) {
let message = ts.flattenDiagnosticMessageText(diagnostic.messageText, "\n");
let {line, character} = diagnostic.file.getLineAndCharacterOfPosition(diagnostic.start);
console.log(`${diagnostic.file.fileName} (${line + 1},${character + 1}): ${message}`);
}
expect(diagnostics.length).toBe(0);
}
export function allChildren<T>(node: ts.Node, cb: (node: ts.Node) => T) {
return ts.forEachChild(node, child => {
const result = cb(node);
if (result) {
return result;
}
return allChildren(child, cb);
})
}
export function findVar(sourceFile: ts.SourceFile, name: string): ts.VariableDeclaration {
return allChildren(sourceFile,
node => isVar(node) && isNamed(node.name, name) ? node : undefined);
}
export function findClass(sourceFile: ts.SourceFile, name: string): ts.ClassDeclaration {
return ts.forEachChild(sourceFile,
node => isClass(node) && isNamed(node.name, name) ? node : undefined);
}
export function isVar(node: ts.Node): node is ts.VariableDeclaration {
return node.kind === ts.SyntaxKind.VariableDeclaration;
}
export function isClass(node: ts.Node): node is ts.ClassDeclaration {
return node.kind === ts.SyntaxKind.ClassDeclaration;
}
export function isNamed(node: ts.Node, name: string): node is ts.Identifier {
return node.kind === ts.SyntaxKind.Identifier && (<ts.Identifier>node).text === name;
}