Skip to content

Commit

Permalink
Nested modifications (#6)
Browse files Browse the repository at this point in the history
<!--- START AUTOGENERATED NOTES --->
# Contents ([#6](#6))

### Other
- add tests for prototype interactions while setting new props values as
objects
- documents spy behaviour on detached access to nexted properties

<!--- END AUTOGENERATED NOTES --->
  • Loading branch information
vvscode committed Nov 27, 2023
1 parent a4aa6c2 commit 0cb1299
Show file tree
Hide file tree
Showing 2 changed files with 314 additions and 4 deletions.
292 changes: 292 additions & 0 deletions src/create-spy.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -320,4 +320,296 @@ describe('createSpy', () => {
expect(spyHistoryMock.getAll()).toMatchInlineSnapshot(`undefined`);
});
});

describe('createSpy with dynamically added nested structures', () => {
it('should make dynamically added nested object properties observable', () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const originalObject: any = {
a: 1,
b: { x: [1] },
};

const spyObject = createSpy(originalObject, spyHistoryMock);

// Access properties of the nested object
spyObject.b.x;

// Dynamically add a nested object
spyObject.y = { d: 2, e: { k: 1 } };

// Access properties of the dynamically added nested object
spyObject.y.d;

// Check if history.put is called with the correct arguments for nested object properties
expect(spyHistoryMock.put.mock.calls).toMatchInlineSnapshot(`
[
[
{
"key": "b",
"type": "get",
},
],
[
{
"key": "b.x",
"type": "get",
},
],
[
{
"key": "y",
"type": "set",
"value": {
"d": 2,
"e": {
"k": 1,
},
},
},
],
[
{
"key": "y",
"type": "get",
},
],
[
{
"key": "y.d",
"type": "get",
},
],
]
`);
});

it('should make dynamically added nested array elements observable', () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const originalObject: any = {
a: 1,
b: { x: [1] },
};

const spyObject = createSpy(originalObject, spyHistoryMock);

// Access elements of the nested array
spyObject.b.x.map((el: number) => el + 1);

// Dynamically add a nested array
spyObject.y = [2, 3, 4];

// Access elements of the dynamically added nested array
spyObject.y[1];

// Check if history.put is called with the correct arguments for nested array elements
expect(spyHistoryMock.put.mock.calls).toMatchInlineSnapshot(`
[
[
{
"key": "b",
"type": "get",
},
],
[
{
"key": "b.x",
"type": "get",
},
],
[
{
"key": "b.x.map",
"type": "get",
},
],
[
{
"args": [
[Function],
],
"key": "b.x.map",
"type": "call",
},
],
[
{
"key": "b.x.length",
"type": "get",
},
],
[
{
"key": "b.x.constructor",
"type": "get",
},
],
[
{
"key": "b.x.0",
"type": "get",
},
],
[
{
"key": "y",
"type": "set",
"value": [
2,
3,
4,
],
},
],
[
{
"key": "y",
"type": "get",
},
],
[
{
"key": "y.1",
"type": "get",
},
],
]
`);
});

it('should make dynamically added nested function calls observable', () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const originalObject: any = {
a: 1,
b: { x: [1] },
};

const spyObject = createSpy(originalObject, spyHistoryMock);

// Call the nested function
spyObject.b.x.map((num: number) => num * 2);

// Dynamically add a nested function
spyObject.y = (num: number) => num + 1;

// Call the dynamically added nested function
spyObject.y(5);

// Check if history.put is called with the correct arguments for nested function calls
expect(spyHistoryMock.put.mock.calls).toMatchInlineSnapshot(`
[
[
{
"key": "b",
"type": "get",
},
],
[
{
"key": "b.x",
"type": "get",
},
],
[
{
"key": "b.x.map",
"type": "get",
},
],
[
{
"args": [
[Function],
],
"key": "b.x.map",
"type": "call",
},
],
[
{
"key": "b.x.length",
"type": "get",
},
],
[
{
"key": "b.x.constructor",
"type": "get",
},
],
[
{
"key": "b.x.0",
"type": "get",
},
],
[
{
"key": "y",
"type": "set",
"value": [Function],
},
],
[
{
"key": "y",
"type": "get",
},
],
[
{
"args": [
5,
],
"key": "y",
"type": "call",
},
],
]
`);
});
});

describe('spying behavior with detached property access', () => {
it('should record history for nested property access on detached interaction', () => {
const originalObject = { a: { b: { c: { d: 1 } } } };
const spiedObject = createSpy(originalObject, spyHistoryMock);

// Access properties
const a = spiedObject.a;
const b = a.b;
const c = b.c;
const d = c.d;
d; // just to cover unused const creation

// Check if history.put is called with the correct arguments
expect(spyHistoryMock.put.mock.calls).toMatchInlineSnapshot(`
[
[
{
"key": "a",
"type": "get",
},
],
[
{
"key": "a.b",
"type": "get",
},
],
[
{
"key": "a.b.c",
"type": "get",
},
],
[
{
"key": "a.b.c.d",
"type": "get",
},
],
]
`);
});
});
});
26 changes: 22 additions & 4 deletions src/create-spy.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
import { type History, isFunction, isObject } from './types';

// eslint-disable-next-line @typescript-eslint/ban-types -- it's require by Reflect.apply
const shouldProxy = (value: unknown): value is object | Function =>
const isValidSpyTarget = (value: unknown): value is object | Function =>
isObject(value) || isFunction(value);

const shouldProxy = (value: unknown, key: string | symbol) =>
isValidSpyTarget(value) && !(isFunction(value) && (key === 'prototype' || key === 'constructor'));

export function createSpy<T>(obj: T, history: History): T {
const proxyCache = new Map<string, unknown>();

Expand All @@ -14,27 +17,42 @@ export function createSpy<T>(obj: T, history: History): T {
return proxyCache.get(cacheKey);
}

if (!shouldProxy(target)) {
if (!isValidSpyTarget(target)) {
throw new TypeError('target should be an object');
}

const proxy = new Proxy(target, {
get: (target, key, receiver) => {
let err;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let err: any;
let value;
try {
value = Reflect.get(target, key, receiver);
} catch (error) {
err = error;
}

/**
// Potential direction to research in case shouldProxy() still gives fail cases
// Check if the property is non-writable and non-configurable
// https://stackoverflow.com/a/75150991/3400830
if (
err &&
(err.message.includes("property 'prototype' is a read-only and non-configurable") ||
err.message.includes("Cannot perform 'get' on a proxy that has been revoked"))
) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return (target as any)[key]; // Return the actual value of the property
}
*/

history.put({ type: 'get', key: path.concat(String(key)).join('.') });

if (err) {
throw err;
}

return shouldProxy(value) ? createProxy(value, path.concat(String(key))) : value;
return shouldProxy(value, key) ? createProxy(value, path.concat(String(key))) : value;
},
set: (target, key, value, receiver) => {
history.put({ type: 'set', key: path.concat(String(key)).join('.'), value });
Expand Down

0 comments on commit 0cb1299

Please sign in to comment.