@travetto/test
Version:
Declarative test framework
318 lines (278 loc) • 10.4 kB
text/typescript
import ts from 'typescript';
import { TransformerState, OnCall, DeclarationUtil, CoreUtil, OnMethod, AfterMethod } from '@travetto/transformer';
/**
* Which types are candidates for deep literal checking
*/
export const DEEP_LITERAL_TYPES = new Set(['Set', 'Map', 'Array', 'String', 'Number', 'Object', 'Boolean']);
/**
* Mapping of assert equal methods to assert deep equal methods
*/
export const DEEP_EQUALS_MAPPING: Record<string, string> = {
equal: 'deepEqual',
notEqual: 'notDeepEqual',
strictEqual: 'deepStrictEqual',
notStrictEqual: 'notDeepStrictEqual'
};
/**
* Typescript optoken to assert methods
*/
export const OPTOKEN_ASSERT = {
InKeyword: 'in',
EqualsEqualsToken: 'equal',
ExclamationEqualsToken: 'notEqual',
EqualsEqualsEqualsToken: 'strictEqual',
ExclamationEqualsEqualsToken: 'notStrictEqual',
GreaterThanEqualsToken: 'greaterThanEqual',
GreaterThanToken: 'greaterThan',
LessThanEqualsToken: 'lessThanEqual',
LessThanToken: 'lessThan',
InstanceOfKeyword: 'instanceof',
AmpersandAmpersandToken: '', // Default to assert
BarBarToken: '' // Default to assert
};
const ASSERT_CMD = 'assert';
const ASSERT_UTIL = 'AssertCheck';
/**
* Special methods with special treatment (vs just boolean checking)
*/
const METHODS: Record<string, Function[]> = {
includes: [Array, String],
test: [RegExp]
};
const OP_TOKEN_TO_NAME = new Map<number, keyof typeof OPTOKEN_ASSERT>();
const AssertSymbol = Symbol();
const IsTestSymbol = Symbol();
/**
* Assert transformation state
*/
interface AssertState {
[AssertSymbol]?: {
assert: ts.Identifier;
hasAssertCall?: boolean;
assertCheck: ts.Expression;
checkThrow: ts.Expression;
checkThrowAsync: ts.Expression;
};
[IsTestSymbol]?: boolean;
}
/**
* List of assertion arguments
*/
type Args = ts.Expression[] | ts.NodeArray<ts.Expression>;
/**
* Assertion message
*/
type Message = ts.Expression | undefined;
/**
* A specific assertion command
*/
interface Command {
fn: string;
args: ts.Expression[];
negate?: boolean;
}
/**
* Looks within test files to instrument `assert()` calls to allow for detection,
* and result generation
*/
export class AssertTransformer {
/**
* Resolves optoken to syntax kind. Relies on `ts`
*/
static lookupOpToken(key: number): string | undefined {
if (OP_TOKEN_TO_NAME.size === 0) {
Object.keys(ts.SyntaxKind)
.filter(x => !/^\d+$/.test(x))
.filter((x): x is keyof typeof OPTOKEN_ASSERT => !/^(Last|First)/.test(x))
.forEach(x =>
OP_TOKEN_TO_NAME.set(ts.SyntaxKind[x], x));
}
const name = OP_TOKEN_TO_NAME.get(key)!;
if (name in OPTOKEN_ASSERT) {
return OPTOKEN_ASSERT[name];
} else {
return;
}
}
/**
* Determine if element is a deep literal (should use deep comparison)
*/
static isDeepLiteral(state: TransformerState, node: ts.Expression): boolean {
let found = ts.isArrayLiteralExpression(node) ||
ts.isObjectLiteralExpression(node) ||
(
ts.isNewExpression(node) &&
DEEP_LITERAL_TYPES.has(node.expression.getText())
);
// If looking at an identifier, see if it's in a diff file or if its const
if (!found && ts.isIdentifier(node)) {
found = !!state.getDeclarations(node).find(x =>
// In a separate file or is const
x.getSourceFile().fileName !== state.source.fileName ||
DeclarationUtil.isConstantDeclaration(x));
}
return found;
}
/**
* Initialize transformer state
*/
static initState(state: TransformerState & AssertState): void {
if (!state[AssertSymbol]) {
const asrt = state.importFile('@travetto/test/src/assert/check.ts').ident;
state[AssertSymbol] = {
assert: asrt,
assertCheck: CoreUtil.createAccess(state.factory, asrt, ASSERT_UTIL, 'check'),
checkThrow: CoreUtil.createAccess(state.factory, asrt, ASSERT_UTIL, 'checkThrow'),
checkThrowAsync: CoreUtil.createAccess(state.factory, asrt, ASSERT_UTIL, 'checkThrowAsync'),
};
}
}
/**
* Convert the assert to call the framework `AssertUtil.check` call
*/
static doAssert(state: TransformerState & AssertState, node: ts.CallExpression, cmd: Command): ts.CallExpression {
this.initState(state);
const first = CoreUtil.firstArgument(node);
const firstText = first?.getText() ?? node.getText();
cmd.args = cmd.args.filter(x => x !== undefined && x !== null);
const check = state.factory.createCallExpression(state[AssertSymbol]!.assertCheck, undefined, state.factory.createNodeArray([
state.fromLiteral({
module: state.getModuleIdentifier(),
line: state.fromLiteral(ts.getLineAndCharacterOfPosition(state.source, node.getStart()).line + 1),
text: state.fromLiteral(firstText),
operator: state.fromLiteral(cmd.fn)
}),
state.fromLiteral(!cmd.negate),
...cmd.args
]));
return check;
}
/**
* Convert `assert.(throws|rejects|doesNotThrow|doesNotReject)` to the appropriate structure
*/
static doThrows(state: TransformerState & AssertState, node: ts.CallExpression, key: string, args: ts.Expression[]): ts.CallExpression {
const first = CoreUtil.firstArgument(node)!;
const firstText = first.getText();
this.initState(state);
return state.factory.createCallExpression(
/reject/i.test(key) ? state[AssertSymbol]!.checkThrowAsync : state[AssertSymbol]!.checkThrow,
undefined,
state.factory.createNodeArray([
state.fromLiteral({
module: state.getModuleIdentifier(),
line: state.fromLiteral(ts.getLineAndCharacterOfPosition(state.source, node.getStart()).line + 1),
text: state.fromLiteral(`${key} ${firstText}`),
operator: state.fromLiteral(`${key}`)
}),
state.fromLiteral(key.startsWith('doesNot')),
...args
]));
}
/**
* Check a binary expression (left and right) to see how we should communicate the assert
*/
static doBinaryCheck(state: TransformerState, comp: ts.BinaryExpression, message: Message, args: Args): Command {
let opFn = this.lookupOpToken(comp.operatorToken.kind);
if (opFn) {
const literal = this.isDeepLiteral(state, comp.left) ? comp.left : this.isDeepLiteral(state, comp.right) ? comp.right : undefined;
if (/equal/i.test(opFn) && literal) {
opFn = DEEP_EQUALS_MAPPING[opFn] || opFn;
}
return { fn: opFn, args: [comp.left, comp.right, message!] };
} else {
return { fn: ASSERT_CMD, args: [...args] };
}
}
/**
* Check unary operator
*/
static doUnaryCheck(state: TransformerState, comp: ts.PrefixUnaryExpression, message: Message, args: Args): Command {
if (ts.isPrefixUnaryExpression(comp.operand)) {
const inner = comp.operand.operand;
return { fn: 'ok', args: [inner, message!] };
} else {
const inner = comp.operand;
return { ...this.getCommand(state, [inner, ...args.slice(1)])!, negate: true };
}
}
/**
* Check various `assert.*` method calls
*/
static doMethodCall(state: TransformerState, comp: ts.Expression, args: Args): Command {
if (ts.isCallExpression(comp) && ts.isPropertyAccessExpression(comp.expression)) {
const root = comp.expression.expression;
const key = comp.expression.name;
const matched = METHODS[key.text!];
if (matched) {
const resolved = state.resolveType(root);
if (resolved.key === 'literal' && matched.find(x => resolved.ctor === x)) { // Ensure method is against real type
switch (key.text) {
case 'includes': return { fn: key.text, args: [comp.expression.expression, comp.arguments[0], ...args.slice(1)] };
case 'test': return { fn: key.text, args: [comp.arguments[0], comp.expression.expression, ...args.slice(1)] };
}
}
}
}
return { fn: ASSERT_CMD, args: [...args] };
}
/**
* Determine which type of check to perform
*/
static getCommand(state: TransformerState, args: Args): Command | undefined {
const comp = args[0]!;
const message = args.length === 2 ? args[1] : undefined;
if (ts.isParenthesizedExpression(comp)) {
return this.getCommand(state, [comp.expression, ...args.slice(1)]);
} else if (ts.isBinaryExpression(comp)) {
return this.doBinaryCheck(state, comp, message, args);
} else if (ts.isPrefixUnaryExpression(comp) && comp.operator === ts.SyntaxKind.ExclamationToken) {
return this.doUnaryCheck(state, comp, message, args);
} else {
return this.doMethodCall(state, comp, args);
}
}
static onAssertCheck(state: TransformerState & AssertState, node: ts.MethodDeclaration): ts.MethodDeclaration {
state[IsTestSymbol] = true;
return node;
}
static afterAssertCheck(state: TransformerState & AssertState, node: ts.MethodDeclaration): ts.MethodDeclaration {
state[IsTestSymbol] = false;
return node;
}
/**
* Listen for all call expression
*/
static onAssertCall(state: TransformerState & AssertState, node: ts.CallExpression): ts.CallExpression {
// Only check in test mode
if (!state[IsTestSymbol]) {
return node;
}
const exp = node.expression;
// Determine if calling assert directly
if (ts.isIdentifier(exp) && exp.getSourceFile() && exp.getText() === ASSERT_CMD) { // Straight assert
const cmd = this.getCommand(state, node.arguments);
if (cmd) {
node = this.doAssert(state, node, cmd);
}
// If calling `assert.*`
} else if (ts.isPropertyAccessExpression(exp) && ts.isIdentifier(exp.expression)) { // Assert method call
const ident = exp.expression;
const fn = exp.name.escapedText.toString();
if (ident.escapedText === ASSERT_CMD) {
// Look for reject/throw
if (fn === 'fail') {
node = this.doAssert(state, node, { fn: 'fail', args: node.arguments.slice() });
} else if (/^(doesNot)?(Throw|Reject)s?$/i.test(fn)) {
node = this.doThrows(state, node, fn, [...node.arguments]);
} else {
const sub = { ...this.getCommand(state, node.arguments)!, fn };
node = this.doAssert(state, node, sub);
}
}
}
return node;
}
}