Skip to content

Commit

Permalink
Refactored the way code is being parsed, removed regex that didn't wo…
Browse files Browse the repository at this point in the history
…rk well in all cases, implemented JS parser and AST traversing to better handle code
  • Loading branch information
rezoled committed Jul 4, 2024
1 parent 97d1ff5 commit 9b5b08a
Show file tree
Hide file tree
Showing 6 changed files with 145 additions and 42 deletions.
11 changes: 5 additions & 6 deletions src/Mock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -188,12 +188,11 @@ export class Mocker {

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);
});
const fullClass = this.objectPropertyCodeRetriever.getObject(obj);
const functionNames = this.mockableFunctionsFinder.find(fullClass);
functionNames.forEach((functionName: string) => {
this.createMethodStub(functionName);
this.createInstanceActionListener(functionName, this.clazz.prototype);
});
});
}
Expand Down
107 changes: 99 additions & 8 deletions src/utils/MockableFunctionsFinder.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,96 @@
import {parse} from "@babel/parser";
import * as _babel_types from "@babel/types";

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 +102,16 @@
* - [.]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"];
private excludedFunctionNames = new Set(["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);
const ast = parse(code);
const names = extractFunctionNames(ast.program.body);
return names
.filter((functionName: string) => this.isMockable(functionName));
}

private isMockable(name: string): boolean {
return this.excludedFunctionNames.indexOf(name) < 0;
return !this.excludedFunctionNames.has(name);
}
}
1 change: 1 addition & 0 deletions src/utils/ObjectInspector.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import * as _ from "lodash";


export class ObjectInspector {
public getObjectPrototypes(prototype: any): any[] {
const prototypes: any[] = [];
Expand Down
41 changes: 28 additions & 13 deletions src/utils/ObjectPropertyCodeRetriever.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,32 @@
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 getObject(object: any) {
const props = Object.getOwnPropertyNames(object);
return `class Prototype {
${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' : prop;
result += `
${propName} = ${String(object[prop])}
`;
}
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]);
`;
}

}
9 changes: 4 additions & 5 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 @@ -30,15 +29,15 @@ describe("MockableFunctionsFinder", () => {

function getSampleCode(): string {
return `
export class Foo {
constructor (private temp:string) {
class Foo {
constructor (temp) {
this.anonymousMethod = function(arg) {
console.log(arg);
temp.hasOwnProperty("fakeProperty");
}
}
private convertNumberToString(value:number):string {
convertNumberToString(value) {
return value.toString();
}
}
Expand Down
18 changes: 8 additions & 10 deletions test/utils/ObjectPropertyCodeRetriever.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,22 +22,20 @@ describe("ObjectPropertyCodeRetriever", () => {
};
});

it("Provides code of given existing property", () => {
expect(objectPropertyCodeRetriever.get(object, "undefinedProperty")).toBe("undefined");
expect(objectPropertyCodeRetriever.get(object, "nullProperty")).toBe("null");
expect(objectPropertyCodeRetriever.get(object, "nanProperty")).toBe("NaN");
expect(objectPropertyCodeRetriever.get(object, "stringProperty")).toBe("stringProperty");
expect(objectPropertyCodeRetriever.get(object, "booleanProperty")).toBe("true");
expect(objectPropertyCodeRetriever.get(object, "testMethod")).toMatch(/function \(\)/);
it("Provides code of given existing property method", () => {
const objStr = objectPropertyCodeRetriever.getObject(object);
expect(objStr).toContain('testMethod = function () { return true; }');
});

it("Provides code of given existing property accessors", () => {
expect(objectPropertyCodeRetriever.get(object, "someValue")).toMatch(/return "someValue"/);
expect(objectPropertyCodeRetriever.get(object, "someValue")).toMatch(/console\.info\("someValue set"\)/);
const objStr = objectPropertyCodeRetriever.getObject(object);
expect(objStr).toMatch(/get someValue\(\) \{\s*return "someValue";\s*}/);
expect(objStr).toMatch(/set someValue\(newValue\) \{\s*console.info\("someValue set"\);\s*}/);
});

it("Returns empty string when checking non existent property", () => {
expect(objectPropertyCodeRetriever.get(object, "nonExistentProperty")).toBe("");
const objStr = objectPropertyCodeRetriever.getObject(object);
expect(objStr).not.toContain("nonExistentProperty");
});
});
});

0 comments on commit 9b5b08a

Please sign in to comment.