Skip to content

Commit

Permalink
Merge remote-tracking branch 'origin/master'
Browse files Browse the repository at this point in the history
  • Loading branch information
rezoled committed Jul 15, 2024
2 parents 07ef837 + 22d9e9c commit 1e52e6d
Show file tree
Hide file tree
Showing 7 changed files with 217 additions and 101 deletions.
23 changes: 3 additions & 20 deletions src/Mock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,15 +8,12 @@ import {ReturnValueMethodStub} from "./stub/ReturnValueMethodStub";
import {strictEqual} from "./ts-mockito";
import {MockableFunctionsFinder} from "./utils/MockableFunctionsFinder";
import {ObjectInspector} from "./utils/ObjectInspector";
import {ObjectPropertyCodeRetriever} from "./utils/ObjectPropertyCodeRetriever";

export class Mocker {
public mock: any = {};
protected objectInspector = new ObjectInspector();
private methodStubCollections: any = {};
private methodActions: MethodAction[] = [];
private mockableFunctionsFinder = new MockableFunctionsFinder();
private objectPropertyCodeRetriever = new ObjectPropertyCodeRetriever();
private excludedPropertyNames: string[] = ["hasOwnProperty"];
private defaultedPropertyNames: string[] = ["Symbol(Symbol.toPrimitive)", "then", "catch"];

Expand All @@ -27,7 +24,6 @@ export class Mocker {
this.processProperties((this.clazz as any).prototype);
if (!isSpy || typeof Proxy === "undefined") {
this.processClassCode(this.clazz);
this.processFunctionsCode((this.clazz as any).prototype);
}
}
if (typeof Proxy !== "undefined" && this.clazz) {
Expand Down Expand Up @@ -123,8 +119,8 @@ export class Mocker {
}

protected processProperties(object: any): void {
this.objectInspector.getObjectPrototypes(object).forEach((obj: any) => {
this.objectInspector.getObjectOwnPropertyNames(obj).forEach((name: string) => {
ObjectInspector.getObjectPrototypes(object).forEach((obj: any) => {
ObjectInspector.getObjectOwnPropertyNames(obj).forEach((name: string) => {
if (this.excludedPropertyNames.indexOf(name) >= 0) {
return;
}
Expand Down Expand Up @@ -178,26 +174,13 @@ export class Mocker {
}

private processClassCode(clazz: any): void {
const classCode = typeof clazz.toString !== "undefined" ? clazz.toString() : "";
const functionNames = this.mockableFunctionsFinder.find(classCode);
const functionNames = this.mockableFunctionsFinder.find(clazz);
functionNames.forEach((functionName: string) => {
this.createMethodStub(functionName);
this.createInstanceActionListener(functionName, this.clazz.prototype);
});
}

private processFunctionsCode(object: any): void {
this.objectInspector.getObjectPrototypes(object).forEach((obj: any) => {
this.objectInspector.getObjectOwnPropertyNames(obj).forEach((propertyName: string) => {
const functionNames = this.mockableFunctionsFinder.find(this.objectPropertyCodeRetriever.get(obj, propertyName));
functionNames.forEach((functionName: string) => {
this.createMethodStub(functionName);
this.createInstanceActionListener(functionName, this.clazz.prototype);
});
});
});
}

private createPropertyStub(key: string): void {
if (this.mock.hasOwnProperty(key)) {
return;
Expand Down
122 changes: 111 additions & 11 deletions src/utils/MockableFunctionsFinder.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,99 @@
import {parse} from "@babel/parser";
import * as _babel_types from "@babel/types";
import {ObjectInspector} from "./ObjectInspector";
import {ObjectPropertyCodeRetriever} from "./ObjectPropertyCodeRetriever";
import {uniq} from "lodash";

type FunctionNode = |
_babel_types.ObjectMethod |
_babel_types.ClassMethod |
_babel_types.ClassPrivateMethod |
_babel_types.FunctionDeclaration |
_babel_types.ClassProperty |
_babel_types.Expression |
_babel_types.FunctionExpression;

const methodTokenName = new Set([
"ObjectMethod",
"ClassMethod",
"ClassPrivateMethod",
"FunctionDeclaration",
"FunctionExpression"
]);

const isFunctionNode = (node: _babel_types.Statement | FunctionNode): node is FunctionNode => methodTokenName.has(node.type);


function getAssignmentName(node: _babel_types.LVal) {
if (node.type === "Identifier")
return node.name;

if (node.type === "MemberExpression") {
const prop = node.property;
if (prop.type === 'Identifier') return prop.name;
if (prop.type === 'PrivateName') return prop.id.name;
return null;
}

return null;
}

function handleClassProp(node: _babel_types.ClassProperty): string {
if (node.value.type !== 'ArrowFunctionExpression' && node.value.type !== 'FunctionExpression') return null;

if ('name' in node.key) return node.key.name;
if ('value' in node.key) return node.key.value.toString();
return null;
}

function handleExpression(node: _babel_types.Expression): string {
if ('expression' in node && typeof node.expression !== 'boolean') return handleExpression(node.expression);

if (node.type === 'AssignmentExpression') {
return getAssignmentName(node.left);
}
}

function handleVariable(node: _babel_types.VariableDeclaration): string[] {
return node.declarations.filter(n => {
if (n.init.type === 'ArrowFunctionExpression') return true;
if (n.init.type === 'FunctionExpression') return true;
return false;
}).map(n => getAssignmentName(n.id));
}

function extractFunctionNames(nodes: (_babel_types.Statement | FunctionNode)[]) {
let names = [] as string[];
nodes.forEach(node => {
if (isFunctionNode(node)) {
if ('key' in node) {
if ('name' in node.key)
names.push(node.key.name);
if ('value' in node.key)
names.push(node.key.value.toString());
}
if ('id' in node && node.id) names.push(node.id.name);
}
if ('body' in node) {
names = [...extractFunctionNames(Array.isArray(node.body) ? node.body as _babel_types.Statement[] : [node.body as _babel_types.Statement]), ...names];
}

if (node.type === "ExpressionStatement") {
names = [handleExpression(node.expression), ...names];
}

if (node.type === "VariableDeclaration") {
names = [...handleVariable(node), ...names];
}

if (node.type === "ClassProperty") {
names = [handleClassProp(node), ...extractFunctionNames([node.value]), ...names];
}
});

return names;
}

/**
* Looking for all function calls and declarations and provides an array of their names. The mechanism is greedy
* and tries to match as many function names as it can find and not only those of inspecting class.
Expand All @@ -9,18 +105,22 @@
* - [.]functionName = function otherName(
*/
export class MockableFunctionsFinder {
private functionNameRegex = /[.\s]([^.\s]+?)(?:\(|\s+=\s+(?:function\s*(?:[^.\s]+?\s*)?)?\()/g;
private cleanFunctionNameRegex = /^[.\s]([^.\s]+?)[\s(]/;
private excludedFunctionNames: string[] = ["hasOwnProperty", "function"];

public find(code: string): string[] {
return (code.match(this.functionNameRegex) || [])
.map((match: string) => match.match(this.cleanFunctionNameRegex)[1])
.filter((functionName: string) => this.isMockable(functionName))
.map((functionName: string) => functionName.indexOf('=') > 0 ? functionName.substr(0, functionName.indexOf('=')) : functionName);
private excludedFunctionNames = new Set(["hasOwnProperty", "function"]);

public find(clazz: any): string[] {
const codes = this.getClassCodeAsStringWithInheritance(clazz);
const names = codes.map(code => parse(code)).flatMap(ast => extractFunctionNames(ast.program.body));
return uniq(names)
.filter((functionName: string) => this.isMockable(functionName));
}

private isMockable(name: string | null | undefined): boolean {
if (!name) return false;
return !this.excludedFunctionNames.has(name);
}

private isMockable(name: string): boolean {
return this.excludedFunctionNames.indexOf(name) < 0;
private getClassCodeAsStringWithInheritance(clazz: any) {
const classCode: string = typeof clazz.toString !== "undefined" ? clazz.toString() : "";
return [classCode, ...ObjectInspector.getObjectPrototypes(clazz.prototype).map(ObjectPropertyCodeRetriever.getObject)];
}
}
5 changes: 3 additions & 2 deletions src/utils/ObjectInspector.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import * as _ from "lodash";


export class ObjectInspector {
public getObjectPrototypes(prototype: any): any[] {
public static getObjectPrototypes(prototype: any): any[] {
const prototypes: any[] = [];
while (_.isObject(prototype) && (prototype !== Object.prototype && prototype !== Function.prototype)) {
prototypes.push(prototype);
Expand All @@ -10,7 +11,7 @@ export class ObjectInspector {
return prototypes;
}

public getObjectOwnPropertyNames(object: any): string[] {
public static getObjectOwnPropertyNames(object: any): string[] {
return _.isObject(object) ? Object.getOwnPropertyNames(object) : [];
}
}
43 changes: 30 additions & 13 deletions src/utils/ObjectPropertyCodeRetriever.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,34 @@

export class ObjectPropertyCodeRetriever {
public get(object: any, propertyName: string): string {
const descriptor = Object.getOwnPropertyDescriptor(object, propertyName);
if (!descriptor) {
// property is defined in prototype but has no descriptor (it comes from abstract class and was not override)
return "";
public static getObject(object: any) {
const props = Object.getOwnPropertyNames(object);
return `class ${object.constructor.name} {
${props.map(prop => {
let result = '';
const descriptor = Object.getOwnPropertyDescriptor(object, prop);
if (descriptor.get) {
result += `
${descriptor.get.toString()}
`;
}
if (descriptor.set) {
result += `
${descriptor.set.toString()}
`;
}
if (!descriptor.get && !descriptor.set && typeof object[prop] === 'function') {
const propName = prop === 'constructor' ? 'mock_constructor' : '';
const fnStr = String(object[prop]);
result += `
${propName ? `${propName}=` : ''}${fnStr}
`;
}
return result;
}).join(`
`)}
}
const accessorsCodes = [];
if (descriptor.get) {
accessorsCodes.push(descriptor.get.toString());
}
if (descriptor.set) {
accessorsCodes.push(descriptor.set.toString());
}
return accessorsCodes.join(" ") || String(object[propertyName]);
`;
}

}
72 changes: 65 additions & 7 deletions test/utils/MockableFunctionsFinder.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,8 @@ describe("MockableFunctionsFinder", () => {
const result = new MockableFunctionsFinder().find(code);

// then
expect(result).toContain("log");
expect(result).toContain("toString");
expect(result).toContain("anonymousMethod");
expect(result).toContain("convertNumberToString");
});

it("should not find hasOwnProperty as it should not be mocked (because its used by mockito to evaluate properties)", () => {
Expand All @@ -26,21 +25,80 @@ describe("MockableFunctionsFinder", () => {
expect(result["hasOwnProperty"] instanceof Function).toBeTruthy();
});
});

describe("searching for method names in complex class code", () => {
const mockableFunctionsFinder = new MockableFunctionsFinder();
let mockableMethods: string[];

beforeEach(() => {
// tslint:disable-next-line:no-eval
const object = getSampleComplexClassCode();
mockableMethods = mockableFunctionsFinder.find(object);
});

it("should find existing property method", () => {
expect(mockableMethods).toContain('testMethod');
expect(mockableMethods).toContain('testMethod2');
expect(mockableMethods).toContain('testMethod3');
});

it("should find existing existing property accessors", () => {
expect(mockableMethods).toContain('someValue');
});

it("should not find non existent property", () => {
expect(mockableMethods).not.toContain("nonExistentProperty");
});
});
});

function getSampleCode(): string {
return `
export class Foo {
constructor (private temp:string) {
// tslint:disable-next-line:no-eval
return eval(`
class Foo {
constructor (temp) {
this.anonymousMethod = function(arg) {
console.log(arg);
temp.hasOwnProperty("fakeProperty");
}
}
private convertNumberToString(value:number):string {
convertNumberToString(value) {
return value.toString();
}
}
`;
Foo;
`);
}

function getSampleComplexClassCode() {
// tslint:disable-next-line:no-eval
return eval(`
class InheritedTest {
undefinedProperty = undefined;
nullProperty = null;
nanProperty = NaN;
stringProperty = "stringProperty";
booleanProperty = true;
testMethod = () => true;
testMethod2 = function () { return true };
get someValue() {
return "someValue";
}
set someValue(newValue) {
console.info("someValue set");
}
}
class Test extends InheritedTest {
testMethod3() {
return 'barbaz';
}
}
Test;
`);
}
Loading

0 comments on commit 1e52e6d

Please sign in to comment.