top of page

A Mock Mock Object Typescript Library Based on JavaScript Proxies

  • Writer: Eldan Ben Haim
    Eldan Ben Haim
  • Mar 22, 2022
  • 14 min read

Recently I had the opportunity to fiddle a little bit with MongoDB’s C# driver [link here]. A nice oddity of that driver is its use of C#’s excellent expression tree support (Linq expressions) — a feature that I was always very fond of. This C# feature allows us to write C# expressions in-line in our source code, that get compiled to an expression tree literal available in runtime. So, for example, the MongoDB driver can map documents to C# types, and can receive expressions that define predicates on these types as query filters. Or, instead of referring to a field name (or path) as a string when defining projections, one can specify a C# expression that returns the desired field and return it. The following concise example illustrate building a simple projection in C#:

class Widget {     
    [BsonElement("x")]     
    public int X { get; set; }      
    
    [BsonElement("y")]     
    public int Y { get; set; } }
}

var projection = Builders<Widget>.Projection
     .Include(x => x.X)
     .Include(x => x.Y)
     .Exclude(x => x.Id); 

Note how the calls to Include() above pass a lambda expression that receives an object of type Widget and resolves a field. This lambda is actually passed to the driver as an expression tree, from which the driver constructs a MongoDB projection definition object. Of course, we could also write something like this:

var projection = Builders<Person>.Projection
    .Include(p => p.Address.City);

to traverse through a whole field path. Obviously this API brings tremendous improvements to type-safety and early catch of database access errors; moreover automated refactoring of data model classes automatically apply to query definitions (although, alas, they could break access to existing data).


I’ve been wanting to write a blog post on JavaScript Proxy objects for a while now, but didn’t really find a use-case that was both concise enough to present, and yet realistic enough to engage. But after working with the C# mongo driver, I realized that one could use proxies to implement a TypeScript querying API that would look something like this:

const queryStatement = query<Person>((personQuery) => { 
  personQuery.and(
    personQuery.firstName.eq(”Joe”),
    personQuery.lastName.eq(”Cool”))
  });

Where personQuery above will have proper and complete typing to support this syntax.


Alas, after contemplating this task for a few days I realized it’s too big to present in a blog post. All is not lost, however, as this thought experiment did lead me to another concise yet useful example: mock objects. So here we are.


Mock Objects: Requirements

Let’s start by formulating our goal here. We’re all adults here, so we know that mock objects are utilities typically used for testing code (and specifically unit testing). They implement the interface of the “real” object that they mock, so that object can be replaced by them, but they are built to return specific values to drive testing code. They are also sometimes used to “track” code execution by recording calls to methods invoked on them — but for the sake of simplicity we’ll ignore this aspect of mock objects for now.


It would be nice if we could define the behavior we would like from a mock object in very concise terms. For example, ”field x’s value should be 9” or “method f() should return an empty object”. Moreover, given the expected type of a mock object we should leverage TypeScript type checking to guide us towards creating a mock object that complies with a specific interface or type.


The following code snippet is a good example of how a framework that we'll consider as a success could be used:

class FirstObject {
  a: number;
  b: SecondObject;
  c: (n: number) => SecondObject;
}

class SecondObject {
  z: SecondObject;
  zz: FirstObject;

  constructor(public d:number) {}
}

class ThirdObject extends  SecondObject {
  we: string;
}

const mockObject = mock<FirstObject>((setup) => {
  setup.a.returns(3);  
  setup.c(4).d.returns(16);
  setup.c(9).setup((o) => { 
    o.d.returns(99);
    o.z.d.returns(101);
  });
  setup.c(11).zz.c(12).d.returns(3.14);
});

We expect typing to precise. So, setup.a.returns above should accept a number parameter, there’s no setup.zz, etc.


JavaScript Proxy Objects

A key observation on the example above is that the mock() function passes an object to the callback it received (as argument setup) that has members that correspond to FirstObject members, named a and c. Since TypeScript removes typing information in runtime, mock()’s implementation can’t know the type of the expected returned object — so how come it’s able to create an object with exactly the right properties?


The answer is that it can’t. The object returned by mock() is in fact a JavaScript Proxy object. Proxy objects are constructed with an underlying object and a set of handlers that respond to object access operations such as resolving an attribute, setting an attribute or calling (for function objects). Consider this trivial example:


const p: any = new Proxy({}, {
  get(target, propertyName) {
    console.log("getting ", propertyName);
    return "dummy-value";
  }
});

console.log(p.attr1);
console.log(p.attr2);

For each lookup of an attribute we will see a log entry indicating that our get() handler was invoked with the attribute name, and the logged attribute value will be “dummy-value” as returned from the handler. So the output we'll see is:

getting  attr1
dummy-value
getting  attr2
dummy-value

Our mock() will use a similar technique to return an object that effectively responds to any attribute lookup, not only attributes defined by the mocked type. However we will use TypeScript type checking to assert that the proxy conforms to an interface that only includes attributes that correspond to the original type.


The following example demonstrates the technique: a proxy that simply logs attribute lookups, and is asserted to comply with a specific interface:

function typedProxy<T>() : T {
  return new Proxy({}, {
    get(target, propertyName) {
      console.log("getting ", propertyName);
      return "dummy-value";
    }
  }) as T;
}

Just like before, the returned object would respond to attempts to lookup any attribute, but its type at design time is asserted to be that of T.


Mocking Simple Attributes Ain’t Simple

Starting small, let’s look at a mock object that only supports setting values for attributes, as in the following snippet:

const mockObject = mock<FirstObject>((setup) => {
  setup.a.returns(3);  
  setup.a.b.d.returns(9);  
});

First we want to define a TypeScript type that describes setup above, which we’ll call the mock setup type. This type has “setup members” that correspond to each member of the mocked type; each setup member is an object with a method returns that receives an argument of the original member’s type. If the member’s type is an object, we would like the “setup member”’s type to also recursively include setup members corresponding to members of that type. This is rather easy to do in TypeScript:

type PropertySetup<T> = 
  ObjectSetup<T> & 
  { 
    returns(t: T): void; 
  };

type ObjectSetup<O> = {
  [field in keyof O]: PropertySetup<O[field]>;
};

Type ObjectSetup<O> will return the mock setup type for O. Each member of O is mapped to a PropertySetup type, that recursively includes an ObjectSetup member for each underlying member, in addition to concrete setup functions — in our case, returns.


Our mock<T>() function will basically create a new Proxy object, cast to the ObjectSetup type, and invoke the callback it received with it:

function CreateSetupProxy() {
  return new Proxy(…);
}

function mock<T>(setupFunction: (d: ObjectSetup<T>) => void) {
  const objSetup = CreateSetupProxy();
  setupFunction(objSetup);
}

Note that we do not return anything from mock() yet, and also omitted how the proxy is actually created. The proxy setup code was factored outside of mock() for reasons that will become obvious soon. Real soon. Actually, right now.


Our proxy’s get() handler implementation needs to realize an object that complies to the ObjectSetup interface. That is, for any attribute that’s being looked up, we need to return an object that would include the returns method (and in the near future additional setup methods), but also members corresponding to all members of the attribute’s original type — recursively (these are the setup members). These members will to need to comply to the ObjectSetup interface for their corresponding mocked members’ types. Essentially this means that for any attribute lookup that’s not one of the setup methods, we need to return another ObjectSetup proxy to represent that attribute. To do this, CreateSetupProxy will call itself (not really recursively, since this is done on a different call stack) and create the proxy.


Let’s add this bit to the code:

function CreateSetupProxy() {
  const proxyBuilder = new Proxy(valueHolder, {
    get(target, propertyName) {
      if(propertyName === "returns") {
        …
      } else {        
        return CreateSetupProxy();
      }
    }
  });

  return proxyBuilder;
}

function mock<T>(setupFunction: (d: ObjectSetup<T>) => void) {
  const objSetup = CreateSetupProxy();
  setupFunction(objSetup);
}

That’s nice. So given any chained qualification of our setup object (e.g, setup.a.b.c.d), each step in the qualification will go through an instance of our proxy implementation. With this foundation in place we can actually implement some mocking logic.


Since we want the code to describe a mock object to look like setup.a.b.returns(3), the value that should be returned from b is known in setup time only after the ObjectSetup proxy for b was created and already returned to the caller. Once returns() is invoked on that proxy it should somehow communicate to the containing object (stored in attribute a of the mock object) what that value should be, or how it should behave. So, when we construct the ObjectSetup proxy for b we should create a placeholder for b’s eventual value within our mock representation for a, and allow b’s ObjectSetup proxy to update that placeholder when return() is invoked.


We do this by introducing the concept of a mock blueprint, which the proxies collaborate to construct. The mock blueprint describes the created mock objects, and is built as accesses are made to ObjectSetup proxies. An ObjectSetup proxy will receive a blueprint object in which it will set the mocked value for the object it represents as it becomes available. Let’s start with a simple example, only supporting single qualification steps:

class MockBlueprint extends Function {
  constructor() {
    super(); 
  }
  
  compile(): any {}
};

class MockValueBlueprint extends MockBlueprint {
  public v: any;

  constructor() {
    super();
  }

  compile() {
      return this.v;
  }
}

function CreateSetupProxy(valueHolder: MockValueBlueprint) {
  const proxyBuilder = new Proxy(valueHolder, {
    get(target, propertyName) {
      if(propertyName === "returns" ) {
        return (v: any) => { target.v = v; };
      } else {        
        return CreateSetupProxy(…);
      }
    }
});

  return proxyBuilder;
}

function mock<T>(setupFunction: (d: ObjectSetup<T>) => void): T {
  const mockData = new MockValueBlueprint();
  
  const objSetup = CreateSetupProxy(mockData);
  setupFunction(objSetup);

  return mockData.compile();
}

Note that we are defining both a general MockBlueprint and a specific MockValueBlueprint. We’ll see that supporting more than one type of blueprint will become useful. A blueprint has a compile() method that allows us to return the compiled mock object from it. A MockValueBlueprint is a specific type of blueprints, that simply holds a value to be returned. Its compile() method, being the resourceful character that it is, returns that value. Also note that I chose to derive MockBlueprint from Function. We’ll understand why when we get to function mocking.


For now, let’s try to figure out what happens in a simple mocking scenario like this (the scenario below won't really compile because of our type definitions, but it would work if only we'd relax our typing):

const mockObject = mock<FirstObject>((setup) => {
  setup.returns({});  
});

mock() will create an ObjectSetup proxy with a new MockValueBlueprint. The proxy’s get handler will then be invoked with returns as the property name. The handler will return a function that will assign the parameter it receives to the MockValueBlueprint’s underlying value, v. That function is then invoked with an empty object. So essentially, the MockValueBlueprint now points to an empty object. And that’s what‘s returned from mock().


Let’s now add support for member qualification -- supporting stuff like setup.a.b.returns(3). We start with defining a new type of MockBlueprint for describing mock objects; each propretry of the mock object is a value placeholder — a MockValueBlueprint.

class MockObjectBlueprint extends MockBlueprint {
  public v: { [index: string | symbol]: MockValueBlueprint } = {};

  constructor() {
    super();
  }

  compile() {
    const ret = {};
    Object.entries(this.v).forEach(([k, v]) => {
      ret[k] = v.compile();
    });

    return ret;
  }
}

We’re also going to make a small change to MockValueBlueprint to allow it to compile its contained value if it’s a blueprint rather than a “real” value:

class MockValueBlueprint extends MockBlueprint {
  public v: any;

  constructor() {
    super();
  }

  compile() {
    if(this.v instanceof MockBlueprint) {
      return this.v.compile();
    } else {
      return this.v;
    }
  }
}

Next, we update our proxy handle to use MockObjectBlueprints to support qualified attributes:

function CreateSetupProxy(valueHolder: MockValueBlueprint) {
  const proxyBuilder = new Proxy(valueHolder, {
    get(target, propertyName) {
      if(propertyName === "returns" ) {
        return (v: any) => { target.v = v; };
      } else {        
        if(!target.v) {
          target.v = new MockObjectBlueprint();
        }

        let mockObject = target.v;
        if(!(mockObject instanceof MockObjectBlueprint)) {
          throw new Error("Attempt to dereference property of non object blueprint")
        }

        if(!mockObject.v[propertyName]) {
          mockObject.v[propertyName] = new MockValueBlueprint();
        } 

        return CreateSetupProxy(mockObject.v[propertyName]);
      }
    }
  });

  return proxyBuilder;
}

When our ObjectSetup proxy is used to access any qualified attribute, the code above does the following:

  1. Decide that the value returned by this ObjectSetup proxy is a mock object; create a MockObjectBlueprint to represent it if not already available.

  2. In the MockObjectBlueprint representing this ObjectSetup proxy’s retuned value, create an attribute named the same as the accessed attribute, with a MockValueBlueprint value placeholder as its value.

  3. Create a new ObjectSetup proxy, with the MockValueBlueprint as its blueprint for update, and return it. If returns() is invoked on that ObjectSetup proxy, it will update the MockValueBlueprint it received — which in turn will update the value bound to the qualified attribute.

The description above applies to accessing indexed attributes as well as qualified attributes. Let's see how this works for an example:

const mockObject = mock<FirstObject>((setup) => {
  setup.a.returns(9);  
});

We start, again, with creating an ObjectSetup proxy for the mock object (the “root proxy”), with an empty MockValueBlueprint. Then, the root proxy’s get handler is invoked with property name a. The proxy creates a new MockObjectBlueprint, and within that creates an attribute a assigned to a new empty MockValueBlueprint. A new ObjectSetup proxy (the “attribute a proxy”) is created with this MockValueBlueprint as its blueprint to update. The returns attribute is retrieved from ”attribute a proxy”, which results in returning a function that’s then invoked with the value “9”. This updates the MockValueBlueprint that was passed to “attribute a proxy” to be updated with value “9”. This MockValueBlueprint is the one that’s assigned to attribute a in our MockObjectBlueprint, so essentially we now have the following structure, roughly:

MockValueBlueprint {
  v = MockObjectBlueprint {
      a = MockValueBlueprint {
         v = 9
      }
  }
}

And compile() on this structure will yield the wanted object structure. A similar flow will work for multi-step qualification such as below:

const mockObject = mock<FirstObject>((setup) => {
  setup.b.d.returns(11);  
});

Nominally Typed Mocks

Our implementation so far allows us to create mocks with values in arbitrary attributes. However these mocks are “generic” Objects. For code that relies on the nominal types of objects this will yield undesired results. As an example, consider the following function:

function shouldBeTrue(firstObject: FirstObject) {
  return firstObject.b instanceof SecondObject;
}

This function is expected to return true when invoked on a FirstObject. But if we use the snippet from above to initialize a mock FirtstObject object and pass it to the function, it will return false. Our mocking library cannot know what type attribute b of FirstObject needs to be; this information is lost in runtime. Hence an Object is created. But perhaps we can ask our clientto provide this information, if they want to maintain nominal typing, like so:

setup.b.ofType(SecondObject).d.returns(11);

To implement this, we first extend our PropertySetup interface to include this ofType method:

type PropertySetup<T> = 
  ObjectSetup<T> & 
  { 
    returns(t: T): void; 
    ofType<R extends T>(t: new(...args) => R): PropertySetup<R>;
    setup(sf: (st: ObjectSetup<T>) => void): void 
  };

The object blueprint is a good place to record the expected type of the mock object, and act upon it when constructing the object:

class MockObjectBlueprint extends MockBlueprint {
  public v: { [index: string | symbol]: MockValueBlueprint } = {};
  public ofType: new (...args) => any;

  constructor() {
    super();
  }

  compile() {
    const ret = this.ofType ? new this.ofType() : {};
    Object.entries(this.v).forEach(([k, v]) => {
      ret[k] = v.compile();
    });

    return ret;
  }
}

Note that we took a very awkward approach to constructing an object of the required type (new this.ofType()). This will not work for all type constructors. There’s in fact a better way to do this if we would implement the mock object as a proxy… can you find what that is? (the full code posted to GitHub and linked below reveals it).

Of course our ObjectSetup proxy should now respond to ofType() in a similar way to the way it has special handling for returns(). Since we now have two such operations, it’s probably time to do some refactoring and take these handlers implementations to an area that’s free from proxy magic logic:

const MockOperationsInterface = {
  returns(proxyBuilder, valueHolder: MockValueBlueprint, retVal) {
    valueHolder.v = retVal;
  },

  ofType(proxyBuilder, valueHolder: MockValueBlueprint, ctor) {
    if(!valueHolder.v) {
      valueHolder.v = new MockObjectBlueprint();
    }
    if(!(valueHolder.v instanceof MockObjectBlueprint)) {
      throw new Error("Can't apply ofType to non mock object");
    }

    valueHolder.v.ofType = ctor;

    return proxyBuilder;
  }
};

function CreateSetupProxy(valueHolder: MockValueBlueprint) {
  const proxyBuilder = new Proxy(valueHolder, {
    get(target, propertyName) {
      if(MockOperationsInterface[propertyName]) {
        return (...args: any) => (MockOperationsInterface[propertyName] as (...any) => any).apply(null, [proxyBuilder, target, ...args]);
      } else {        
        if(!target.v) {
          target.v = new MockObjectBlueprint();
        }

        let mockObject = target.v;
        if(!(mockObject instanceof MockObjectBlueprint)) {
          throw new Error("Attempt to dereference property of non object blueprint")
        }

        if(!mockObject.v[propertyName]) {
          mockObject.v[propertyName] = new MockValueBlueprint();
        } 

        return CreateSetupProxy(mockObject.v[propertyName]);
      }
    }
  });

  return proxyBuilder;
}

The function MockOperationsInterface.ofType above implements the logic for handling ofType(). All it does is record the expected object type in the object blueprint.

Sorted! We can now mock objects with arbitrary nominal types.


Mocking Functions

Now that we're done with simple values, let's look into mocking functions. We're going to look at a simplified implementation, that allows us to specify a return value given a set of JSON-stringifiable parameters. Obviously this can be expanded in many directions, such as wildcards or criteria on parameters, etc.

Our plan of action is as follows: when an ObjectSetup proxy is invoked as a function, we're going to assign to the proxy's underlying MockValueBlueprint object a new blueprint, describing the function. This blueprint -- of a new blueprint class -- will associate parameters to return values, and will eventually be compiled to a function that returns the values based on parameters. Let's start by defining this blueprint class:

class MockFunctionBlueprint extends MockBlueprint {
  public dispatchTable: { [serializedArgs: string]: MockValueBlueprint } = {};
  
  constructor() {
    super();
  }
  
  compile() {
    return (...args: any) => {
      return this.dispatchTable[JSON.stringify(args)].compile();
    }
  };    
}

This blueprint class will be the center of any improvement to function result dispatching: this is where we can implement better parameter serialization, parameter wildcards, etc.

Before we actually teach our proxy to handle function invocations by creating these blueprints, we need to modify our PropertySetup type definition to indicate that invoking mocked functions is allowed (and returns a PropertySetup object for the underlying return value):

type PropertySetup<T> = 
  ObjectSetup<T> & 
  (T extends (...args: any) => any ? (((...args: Parameters<T>) => PropertySetup<ReturnType<T>>)) : ObjectSetup<T>)  & 
  { 
    returns(t: T): void; 
    ofType<R extends T>(t: new(...args) => R): PropertySetup<R>;
    setup(sf: (st: ObjectSetup<T>) => void): void 
  };

We use conditional types here to update the function's signature to reflect the new return type (PropertySetup of the underlying return type) only for function types.


Now, let's update our ObjectSetup proxy to handle invocations. We do this by adding an apply handler to it. Caveat here: the apply handler of a Proxy object seems to only be honored if the target object is a Function object. Which brings us in a nice circle-closing-motion to why we derived MockBlueprint from Function and not a regular object :)

Here's the updated code for our proxy:

function CreateSetupProxy(valueHolder: MockValueBlueprint) {  
  const proxyBuilder = new Proxy(valueHolder, {
    
    get(target, propertyName) {
      if(MockOperationsInterface[propertyName]) {
        return (...args: any) => (MockOperationsInterface[propertyName] as (...any) => any).apply(null, [proxyBuilder, target, ...args]);
      } else {        
        const objectBlueprint = target.bluePrintValue(MockObjectBlueprint);

        if(!objectBlueprint.v[propertyName]) {
          objectBlueprint.v[propertyName] = new MockValueBlueprint();
        } 

        return CreateSetupProxy(objectBlueprint.v[propertyName]);
      }
    }, 

    apply(target, thisArg, args) {
      const invokeResultHolder = new MockValueBlueprint();

      const fnBlueprint = target.blueprintValue(MockFunctionBlueprint);
      fnBlueprint.dispatchTable[JSON.stringify(args)] = invokeResultHolder;      

      return CreateSetupProxy(invokeResultHolder);
    }
  });

  return proxyBuilder;
}

The apply handler above ensures that the MockValueBlueprint that the proxy is updating is set to a MockFunctionBlueprint (we've added a utility blueprintValue method to MockValueBlueprint for this purpose since this is recurring pattern now), inserts the arguments with which the invocation was made to the dispatch table of the function blueprint, and creates an ObjectSetup proxy to set the value of the MockValueBlueprint added to the dispatch table.


Let's see how this would work in this example:

const mockmockmock = mock<FirstObject>((setup) => {
  setup.c(4).d.returns(16);
});

Here, setup.c causes an attribute lookup on the root ObjectSetup proxy, causing the proxy to attach a MockObjectBlueprint to its underlying MockValueBlueprint, create a new MockValueBlueprint and attach it to attribute c in that MockObjectBlueprint. A new ObjectSetup proxy is created (the "c" proxy), for editing that MockValueBlueprint. Since we call that proxy (setup.c(4)), the "c" proxy assigns a MockFunctionBlueprint to its MockValueBlueprint (which means that the object blueprint's c attribute is now pointing to a MockFunctionBlueprint). It then adds a dispatch entry for parameter values [4] to that MockFunctionBlueprint, and returns a new ObjectSetup proxy for editing the return value. setup.c(4).d invokes an attribute get on that proxy, and from there we behave as before. The resulting blueprint looks like this:

MockValueBlueprint {
  v: MockObjectBlueprint {
      c: MockValueBlueprint {     
          v: MockFunctionBlueprint {
                dispatchTable: {
                      '[4]': MockValueBlueprint {
                              v: MockObjectBlueprint {
                                  d: MockValueBlueprint { v: 16 }
                              }
                      }
                }
          }
      }
   }
}

In Closing

Our mock mock object library is by no means complete, but we've created the infrastructure on which we can expand. We can add call logging, wildcard and parameter patterns for functions, stateful mocks etc. with ease based on the principles described here.


JavaScript proxy objects are a powerful tool for lower-level framework programming. They can be used for decorating behavior of underlying objects, or creating objects that mirror other objects' behavior (locally or remotely!). Using TypeScript's advanced typing system, it's possible to assert proxies interface in a precise manner -- which makes them even more usable.


The complete library code discussed here is available on https://github.com/eldanb/mockmocklib.















































Recent Posts

See All

3 Comments


Mark Sheinkman
Mark Sheinkman
Apr 03, 2022

Great read, you somehow tricked me into learning more Javascript. As payback, I'll nitpick a potential problem and suggest a solution: https://www.typescriptlang.org/play?#code/PTAEAcCcHsCMBsCmBbAXKAUBgLgT3IqAAowGR4DKi2AruADwAqAfKALygDeok1NkAOwDO6ABQA3dAJrJYiSAEp2rcdACWAE1ABfUADIuoANoBrUGoGgTiXNABmoRgF103DTOS4A8rABWiAGNsKlpwdGxIGkJtbSwA6GFsc0T0EmgySj4GTl5aQRFQACJGAAtCZGgA6y04fyDQEoBDIXNsFugAd0sAA1z+YW7QZGoS6A0AGlAhaFAOpuwAfkLtVg5ODFBNvvz0QsbeWcIAxvh4CwBzUGwyoZGxhdB9wg7CIQIAtTtcC8eePkFQOITlEHnMZiYBJ0hEtHi1GgJcBhYhgQFNoPAaNg1AlUHEEkIktthOxQBRcLJ0aIAOREoRUhRYPAEUmNOzyNIZXAhOhMVaOfSgUQbTb8xAAD2wiAEGha3Fp6HhuB0wpFIoe3CMtJcgskoGksnkSjYKnUWl0KtVoFcfzywjEuv1ckUykBpuVDIw8USDWaXi6ACV-sIALIoJ3oCis9mkeSZUL0HJBgoEyAXFYkzi6ZqPBEAbgwTSEfoEgdtQlDBsgADpaSTCtc1C1G0MlZ1LDSk1TbpXCgXfQGkxWnZqk05RABGJSohtNlrXQh2DomKktDtlrvDa5jPHewsAOWgpf65bD8gjUcgHNjXKyCcaqHH6bWWbhCL7QgPR-yQ-kNaTE6nMAZ3MOF4GmK4bkXZdV1pDc7g0LAgA

Also, a commenting system that doesn't ask for my first-born's soul? nice. -Mark Sh.

Like
Eldan Ben Haim
Eldan Ben Haim
Apr 26, 2022
Replying to

Thanks for this! Indeed a valuable improvement.

Like
follow me
  • LinkedIn
  • Twitter

Thanks for submitting!

© 2021 by Eldan Ben-Haim. All rights reserved.

bottom of page