From 1c6da33e7197196ac873f2395f9e4079f08db529 Mon Sep 17 00:00:00 2001 From: lojhan Date: Tue, 17 Mar 2026 16:35:06 -0300 Subject: [PATCH 1/3] feat: support reference mutation through IPC --- src/shared-resources.ts | 55 +++++- src/types.ts | 6 +- .../references/mutation-resource.test.ts | 17 ++ .../references/nested-mutation.test.ts | 43 +++++ .../references/object-mutation.test.ts | 31 ++++ test/__fixtures__/references/resource.ts | 46 +++++ test/integration/shared-resources.test.ts | 10 + test/unit/write-back.test.ts | 174 ++++++++++++++++++ 8 files changed, 380 insertions(+), 2 deletions(-) create mode 100644 test/__fixtures__/references/mutation-resource.test.ts create mode 100644 test/__fixtures__/references/nested-mutation.test.ts create mode 100644 test/__fixtures__/references/object-mutation.test.ts create mode 100644 test/__fixtures__/references/resource.ts create mode 100644 test/unit/write-back.test.ts diff --git a/src/shared-resources.ts b/src/shared-resources.ts index 981b310..aa77170 100644 --- a/src/shared-resources.ts +++ b/src/shared-resources.ts @@ -200,6 +200,54 @@ const remoteProcedureCall = async ( return response.value; }; +const isPlainObject = (v: unknown): v is Record => + v !== null && typeof v === 'object' && !Array.isArray(v); + +const writeBackArray = (original: unknown[], mutated: unknown[]): void => { + const minLen = Math.min(original.length, mutated.length); + + for (let i = 0; i < minLen; i++) { + const origItem = original[i]; + const mutItem = mutated[i]; + + if (isPlainObject(origItem) && isPlainObject(mutItem)) + writeBackObject(origItem, mutItem); + else if (Array.isArray(origItem) && Array.isArray(mutItem)) + writeBackArray(origItem, mutItem); + else original[i] = mutated[i]; + } + + if (original.length > mutated.length) original.splice(mutated.length); + + for (let i = original.length; i < mutated.length; i++) + original.push(mutated[i]); +}; + +const writeBackObject = ( + orig: Record, + mut: Record +): void => { + for (const key of Object.keys(orig)) if (!(key in mut)) delete orig[key]; + + for (const key of Object.keys(mut)) { + if (isPlainObject(orig[key]) && isPlainObject(mut[key])) + writeBackObject( + orig[key] as Record, + mut[key] as Record + ); + else if (Array.isArray(orig[key]) && Array.isArray(mut[key])) + writeBackArray(orig[key] as unknown[], mut[key] as unknown[]); + else orig[key] = mut[key]; + } +}; + +export const writeBack = (original: unknown, mutated: unknown): void => { + if (Array.isArray(original) && Array.isArray(mutated)) + writeBackArray(original, mutated); + else if (isPlainObject(original) && isPlainObject(mutated)) + writeBackObject(original, mutated); +}; + export const extractFunctionNames = (obj: Record) => { const seen = new Set(); let current = obj; @@ -335,7 +383,8 @@ export const handleRemoteProcedureCall = async ( try { const method = methodCandidate.bind(entry.state); - const result = await method(...(message.args || [])); + const callArgs = message.args || []; + const result = await method(...callArgs); child.send({ type: SHARED_RESOURCE_MESSAGE_TYPES.REMOTE_PROCEDURE_CALL_RESULT, @@ -343,6 +392,7 @@ export const handleRemoteProcedureCall = async ( value: { result, latest: state, + mutatedArgs: callArgs, }, } satisfies IPCResponse); } catch (error) { @@ -367,6 +417,9 @@ const constructSharedResourceWithRPCs = ( return async (...args: unknown[]) => { const rpcResult = await remoteProcedureCall(name, prop, args); + for (let i = 0; i < args.length; i++) + writeBack(args[i], rpcResult.mutatedArgs[i]); + for (const rpcKey of rpcs) { if (rpcKey in rpcResult.latest) { delete rpcResult.latest[rpcKey]; diff --git a/src/types.ts b/src/types.ts index ac6a52f..be5a077 100644 --- a/src/types.ts +++ b/src/types.ts @@ -58,7 +58,11 @@ export type IPCResourceResultMessage = { export type IPCRemoteProcedureCallResultMessage = { type: typeof SHARED_RESOURCE_MESSAGE_TYPES.REMOTE_PROCEDURE_CALL_RESULT; id: string; - value?: { result: unknown; latest: Record }; + value?: { + result: unknown; + latest: Record; + mutatedArgs: unknown[]; + }; error?: string; }; diff --git a/test/__fixtures__/references/mutation-resource.test.ts b/test/__fixtures__/references/mutation-resource.test.ts new file mode 100644 index 0000000..1b12bf1 --- /dev/null +++ b/test/__fixtures__/references/mutation-resource.test.ts @@ -0,0 +1,17 @@ +import { assert, test, waitForExpectedResult } from 'poku'; +import { resource } from '../../../src/index.js'; +import { MutatorContext } from './resource.js'; + +test('Test second resource only', async () => { + const mutator = await resource.use(MutatorContext); + const value = await mutator.getValue(); + const array: number[] = []; + + await mutator.mutateArray(array); + + assert.strictEqual( + array[0], + value, + 'Array should contain the value returned by getValue()' + ); +}); diff --git a/test/__fixtures__/references/nested-mutation.test.ts b/test/__fixtures__/references/nested-mutation.test.ts new file mode 100644 index 0000000..0dc1865 --- /dev/null +++ b/test/__fixtures__/references/nested-mutation.test.ts @@ -0,0 +1,43 @@ +import { assert, test } from 'poku'; +import { resource } from '../../../src/index.js'; +import { NestedMutatorContext } from './resource.js'; + +test('Deeply nested array mutation via IPC', async () => { + const mutator = await resource.use(NestedMutatorContext); + const value = await mutator.getValue(); + const obj = { nested: { arr: [] as number[] } }; + + await mutator.pushToNestedArray(obj); + + assert.strictEqual( + obj.nested.arr[0], + value, + 'Nested array should contain the value pushed by the remote method' + ); +}); + +test('Array of objects mutation via IPC', async () => { + const mutator = await resource.use(NestedMutatorContext); + const arr = [{ x: 1 }, { x: 2 }, { x: 3 }]; + + await mutator.mutateArrayOfObjects(arr); + + assert.deepStrictEqual( + arr, + [{ x: 2 }, { x: 3 }, { x: 4 }], + 'Each object in the array should have its x property incremented' + ); +}); + +test('Array truncation via IPC', async () => { + const mutator = await resource.use(NestedMutatorContext); + const arr = [10, 20, 30, 40]; + + await mutator.truncateArray(arr); + + assert.deepStrictEqual( + arr, + [10, 20, 30], + 'Array should be truncated to all but the last element' + ); +}); diff --git a/test/__fixtures__/references/object-mutation.test.ts b/test/__fixtures__/references/object-mutation.test.ts new file mode 100644 index 0000000..c1b1f04 --- /dev/null +++ b/test/__fixtures__/references/object-mutation.test.ts @@ -0,0 +1,31 @@ +import { assert, test } from 'poku'; +import { resource } from '../../../src/index.js'; +import { ObjectMutatorContext } from './resource.js'; + +test('Object property mutation via IPC', async () => { + const mutator = await resource.use(ObjectMutatorContext); + const value = await mutator.getValue(); + const obj: Record = {}; + + await mutator.mutateObject(obj); + + assert.strictEqual( + obj.key, + value, + 'Object property should be set by the remote method' + ); +}); + +test('Object property deletion via IPC', async () => { + const mutator = await resource.use(ObjectMutatorContext); + const obj: Record = { toDelete: 'remove-me', keep: 42 }; + + await mutator.deleteKey(obj); + + assert.strictEqual( + 'toDelete' in obj, + false, + 'Deleted property should not exist on the original object' + ); + assert.strictEqual(obj.keep, 42, 'Non-deleted property should remain'); +}); diff --git a/test/__fixtures__/references/resource.ts b/test/__fixtures__/references/resource.ts new file mode 100644 index 0000000..4250ceb --- /dev/null +++ b/test/__fixtures__/references/resource.ts @@ -0,0 +1,46 @@ +import { resource } from '../../../src/index.js'; + +export const MutatorContext = resource.create(() => { + const value = Math.random(); + return { + mutateArray(arr: number[]) { + arr.push(value); + }, + getValue() { + return value; + }, + }; +}); + +export const ObjectMutatorContext = resource.create(() => { + const value = Math.random(); + return { + mutateObject(obj: Record) { + obj.key = value; + }, + deleteKey(obj: Record) { + delete obj.toDelete; + }, + getValue() { + return value; + }, + }; +}); + +export const NestedMutatorContext = resource.create(() => { + const value = Math.random(); + return { + pushToNestedArray(obj: { nested: { arr: number[] } }) { + obj.nested.arr.push(value); + }, + mutateArrayOfObjects(arr: Array<{ x: number }>) { + for (const item of arr) item.x += 1; + }, + truncateArray(arr: number[]) { + arr.splice(arr.length - 1); + }, + getValue() { + return value; + }, + }; +}); diff --git a/test/integration/shared-resources.test.ts b/test/integration/shared-resources.test.ts index 84f05f2..a1c350d 100644 --- a/test/integration/shared-resources.test.ts +++ b/test/integration/shared-resources.test.ts @@ -21,4 +21,14 @@ describe('Shared Resources', async () => { assert.strictEqual(exitCode, 1, 'Exit Code needs to be 1'); }); + + await it('Reference tests', async () => { + const code = await poku('test/__fixtures__/references', { + noExit: true, + plugins: [sharedResources()], + concurrency: 0, + }); + + assert.strictEqual(code, 0, 'Exit Code needs to be 0'); + }); }); diff --git a/test/unit/write-back.test.ts b/test/unit/write-back.test.ts new file mode 100644 index 0000000..67736e0 --- /dev/null +++ b/test/unit/write-back.test.ts @@ -0,0 +1,174 @@ +import { assert, test } from 'poku'; +import { writeBack } from '../../src/shared-resources.js'; + +test('writeBack — array push', () => { + const original: number[] = []; + writeBack(original, [42]); + assert.deepStrictEqual( + original, + [42], + 'Element should be pushed into the original array' + ); +}); + +test('writeBack — array truncation', () => { + const original = [1, 2, 3, 4]; + writeBack(original, [1, 2, 3]); + assert.deepStrictEqual( + original, + [1, 2, 3], + 'Array should be truncated to match mutated length' + ); +}); + +test('writeBack — array emptied', () => { + const original = [1, 2, 3]; + writeBack(original, []); + assert.deepStrictEqual(original, [], 'Array should be emptied'); +}); + +test('writeBack — array element update', () => { + const original = [1, 2, 3]; + writeBack(original, [1, 99, 3]); + assert.deepStrictEqual( + original, + [1, 99, 3], + 'Middle element should be updated in place' + ); +}); + +test('writeBack — preserves original array reference', () => { + const original: number[] = [1]; + const ref = original; + writeBack(original, [1, 2, 3]); + assert.strictEqual( + original, + ref, + 'Original array reference should be preserved' + ); +}); + +test('writeBack — object property set', () => { + const original: Record = {}; + writeBack(original, { key: 42 }); + assert.strictEqual( + original.key, + 42, + 'Property should be set on the original object' + ); +}); + +test('writeBack — object property deletion', () => { + const original: Record = { toDelete: 'x', keep: 1 }; + writeBack(original, { keep: 1 }); + assert.strictEqual( + 'toDelete' in original, + false, + 'Property should be removed' + ); + assert.strictEqual( + original.keep, + 1, + 'Remaining property should still be present' + ); +}); + +test('writeBack — object property update', () => { + const original: Record = { count: 0 }; + writeBack(original, { count: 5 }); + assert.strictEqual(original.count, 5, 'Property should be updated'); +}); + +test('writeBack — preserves original object reference', () => { + const original: Record = { a: 1 }; + const ref = original; + writeBack(original, { a: 2, b: 3 }); + assert.strictEqual( + original, + ref, + 'Original object reference should be preserved' + ); +}); + +test('writeBack — deeply nested array push', () => { + const original = { nested: { arr: [] as number[] } }; + writeBack(original, { nested: { arr: [7] } }); + assert.deepStrictEqual( + original.nested.arr, + [7], + 'Nested array should have the pushed value' + ); +}); + +test('writeBack — deeply nested object property', () => { + const original = { a: { b: { c: 0 } } }; + writeBack(original, { a: { b: { c: 99 } } }); + assert.strictEqual(original.a.b.c, 99, 'Deep property should be updated'); +}); + +test('writeBack — preserves nested object references', () => { + const inner = { arr: [] as number[] }; + const original = { nested: inner }; + writeBack(original, { nested: { arr: [1, 2] } }); + assert.strictEqual( + original.nested, + inner, + 'Nested object reference should be preserved' + ); + assert.deepStrictEqual( + original.nested.arr, + [1, 2], + 'Nested array should be updated in place' + ); +}); + +test('writeBack — array of objects mutation', () => { + const original = [{ x: 1 }, { x: 2 }]; + writeBack(original, [{ x: 10 }, { x: 20 }]); + assert.deepStrictEqual( + original, + [{ x: 10 }, { x: 20 }], + 'Each object element should be updated' + ); +}); + +test('writeBack — preserves references inside array of objects', () => { + const item0 = { x: 1 }; + const original = [item0, { x: 2 }]; + writeBack(original, [{ x: 99 }, { x: 2 }]); + assert.strictEqual( + original[0], + item0, + 'Object reference inside array should be preserved' + ); + assert.strictEqual( + item0.x, + 99, + 'The referenced object should have its property updated' + ); +}); + +test('writeBack — primitive original is a no-op', () => { + const original = 42 as unknown; + assert.doesNotThrow( + () => writeBack(original, 99), + 'Should not throw for primitive original' + ); +}); + +test('writeBack — null original is a no-op', () => { + assert.doesNotThrow( + () => writeBack(null, { key: 1 }), + 'Should not throw for null original' + ); +}); + +test('writeBack — mismatched types (array vs object) is a no-op', () => { + const original: number[] = [1, 2]; + writeBack(original, { key: 1 }); + assert.deepStrictEqual( + original, + [1, 2], + 'Mismatched type should leave original unchanged' + ); +}); From 5a1daf2633063dc3f210c836f69d2e7f280c4524 Mon Sep 17 00:00:00 2001 From: lojhan Date: Tue, 17 Mar 2026 17:12:54 -0300 Subject: [PATCH 2/3] feat: add mutation capabilities to map, set, date, bigint --- src/shared-resources.ts | 143 +++++++++--- test/__fixtures__/references/resource.ts | 27 +++ .../references/special-types.test.ts | 86 +++++++ test/unit/encode-decode.test.ts | 216 ++++++++++++++++++ test/unit/write-back.test.ts | 112 +++++++++ 5 files changed, 558 insertions(+), 26 deletions(-) create mode 100644 test/__fixtures__/references/special-types.test.ts create mode 100644 test/unit/encode-decode.test.ts diff --git a/src/shared-resources.ts b/src/shared-resources.ts index aa77170..ffcc4a0 100644 --- a/src/shared-resources.ts +++ b/src/shared-resources.ts @@ -182,7 +182,7 @@ const remoteProcedureCall = async ( type: SHARED_RESOURCE_MESSAGE_TYPES.REMOTE_PROCEDURE_CALL, name, method, - args, + args: args.map(encodeArg), id: requestId, } satisfies IPCRemoteProcedureCallMessage, validator: (message): message is IPCRemoteProcedureCallResultMessage => @@ -200,21 +200,121 @@ const remoteProcedureCall = async ( return response.value; }; -const isPlainObject = (v: unknown): v is Record => - v !== null && typeof v === 'object' && !Array.isArray(v); +const ENC_TAG = '__sr_enc'; + +const isPlainObject = (v: unknown): v is Record => { + if (v === null || typeof v !== 'object' || Array.isArray(v)) return false; + const proto = Object.getPrototypeOf(v) as unknown; + return proto === Object.prototype || proto === null; +}; + +const encodeObjectValues = ( + obj: Record +): Record => { + const result: Record = {}; + for (const key of Object.keys(obj)) result[key] = encodeArg(obj[key]); + return result; +}; + +const encodeObject = (v: Record): unknown => + ENC_TAG in v + ? { [ENC_TAG]: 'esc', v: encodeObjectValues(v) } + : encodeObjectValues(v); + +export const encodeArg = (v: unknown): unknown => { + if (v === undefined) return { [ENC_TAG]: 'u' }; + if (typeof v === 'bigint') return { [ENC_TAG]: 'bi', v: v.toString() }; + if (v instanceof Date) return { [ENC_TAG]: 'd', v: v.toISOString() }; + if (v instanceof Map) + return { + [ENC_TAG]: 'm', + v: Array.from(v.entries(), (e) => [encodeArg(e[0]), encodeArg(e[1])]), + }; + if (v instanceof Set) return { [ENC_TAG]: 's', v: Array.from(v, encodeArg) }; + if (Array.isArray(v)) return v.map(encodeArg); + if (isPlainObject(v)) return encodeObject(v); + return v; +}; + +const decodeObjectValues = ( + obj: Record +): Record => { + const result: Record = {}; + for (const key of Object.keys(obj)) result[key] = decodeArg(obj[key]); + return result; +}; + +const decodeEncoded = (enc: Record): unknown => { + const t = enc[ENC_TAG]; + if (t === 'u') return undefined; + if (t === 'bi') return BigInt(enc.v as string); + if (t === 'd') return new Date(enc.v as string); + if (t === 'm') { + const entries = enc.v as [unknown, unknown][]; + return new Map( + entries.map( + (e) => [decodeArg(e[0]), decodeArg(e[1])] as [unknown, unknown] + ) + ); + } + if (t === 's') return new Set((enc.v as unknown[]).map(decodeArg)); + if (t === 'esc') return decodeObjectValues(enc.v as Record); + return decodeObjectValues(enc); +}; + +export const decodeArg = (v: unknown): unknown => { + if (Array.isArray(v)) return v.map(decodeArg); + if (isPlainObject(v)) + return ENC_TAG in v ? decodeEncoded(v) : decodeObjectValues(v); + return v; +}; + +const writeBackDate = (original: Date, mutated: Date): void => { + original.setTime(mutated.getTime()); +}; + +const writeBackMap = ( + original: Map, + mutated: Map +): void => { + original.clear(); + for (const [k, v] of mutated) original.set(k, v); +}; + +const writeBackSet = (original: Set, mutated: Set): void => { + original.clear(); + for (const v of mutated) original.add(v); +}; + +const tryReconcileInPlace = (original: unknown, mutated: unknown): boolean => { + if (isPlainObject(original) && isPlainObject(mutated)) { + writeBackObject(original, mutated); + return true; + } + if (Array.isArray(original) && Array.isArray(mutated)) { + writeBackArray(original, mutated); + return true; + } + if (original instanceof Map && mutated instanceof Map) { + writeBackMap(original, mutated); + return true; + } + if (original instanceof Set && mutated instanceof Set) { + writeBackSet(original, mutated); + return true; + } + if (original instanceof Date && mutated instanceof Date) { + writeBackDate(original, mutated); + return true; + } + return false; +}; const writeBackArray = (original: unknown[], mutated: unknown[]): void => { const minLen = Math.min(original.length, mutated.length); for (let i = 0; i < minLen; i++) { - const origItem = original[i]; - const mutItem = mutated[i]; - - if (isPlainObject(origItem) && isPlainObject(mutItem)) - writeBackObject(origItem, mutItem); - else if (Array.isArray(origItem) && Array.isArray(mutItem)) - writeBackArray(origItem, mutItem); - else original[i] = mutated[i]; + if (!tryReconcileInPlace(original[i], mutated[i])) original[i] = mutated[i]; } if (original.length > mutated.length) original.splice(mutated.length); @@ -230,22 +330,12 @@ const writeBackObject = ( for (const key of Object.keys(orig)) if (!(key in mut)) delete orig[key]; for (const key of Object.keys(mut)) { - if (isPlainObject(orig[key]) && isPlainObject(mut[key])) - writeBackObject( - orig[key] as Record, - mut[key] as Record - ); - else if (Array.isArray(orig[key]) && Array.isArray(mut[key])) - writeBackArray(orig[key] as unknown[], mut[key] as unknown[]); - else orig[key] = mut[key]; + if (!tryReconcileInPlace(orig[key], mut[key])) orig[key] = mut[key]; } }; export const writeBack = (original: unknown, mutated: unknown): void => { - if (Array.isArray(original) && Array.isArray(mutated)) - writeBackArray(original, mutated); - else if (isPlainObject(original) && isPlainObject(mutated)) - writeBackObject(original, mutated); + tryReconcileInPlace(original, mutated); }; export const extractFunctionNames = (obj: Record) => { @@ -383,7 +473,7 @@ export const handleRemoteProcedureCall = async ( try { const method = methodCandidate.bind(entry.state); - const callArgs = message.args || []; + const callArgs = (message.args || []).map(decodeArg); const result = await method(...callArgs); child.send({ @@ -392,7 +482,7 @@ export const handleRemoteProcedureCall = async ( value: { result, latest: state, - mutatedArgs: callArgs, + mutatedArgs: callArgs.map(encodeArg), }, } satisfies IPCResponse); } catch (error) { @@ -416,9 +506,10 @@ const constructSharedResourceWithRPCs = ( if (typeof prop === 'string' && rpcs.includes(prop)) { return async (...args: unknown[]) => { const rpcResult = await remoteProcedureCall(name, prop, args); + const decodedMutatedArgs = rpcResult.mutatedArgs.map(decodeArg); for (let i = 0; i < args.length; i++) - writeBack(args[i], rpcResult.mutatedArgs[i]); + writeBack(args[i], decodedMutatedArgs[i]); for (const rpcKey of rpcs) { if (rpcKey in rpcResult.latest) { diff --git a/test/__fixtures__/references/resource.ts b/test/__fixtures__/references/resource.ts index 4250ceb..1fee538 100644 --- a/test/__fixtures__/references/resource.ts +++ b/test/__fixtures__/references/resource.ts @@ -44,3 +44,30 @@ export const NestedMutatorContext = resource.create(() => { }, }; }); + +export const SpecialTypesMutatorContext = resource.create(() => ({ + mutateDate(d: Date) { + d.setFullYear(2000); + }, + mutateMap(m: Map) { + m.set('added', 99); + m.delete('toRemove'); + }, + mutateSet(s: Set) { + s.add(99); + s.delete(0); + }, + setPropertyToUndefined(obj: Record) { + obj.a = undefined; + }, + pushUndefined(arr: (number | undefined)[]) { + arr.push(undefined); + }, + mutateBigIntArray(arr: bigint[]) { + arr.push(99n); + }, + mutateBigIntMap(m: Map) { + m.set('added', 42n); + m.delete('toRemove'); + }, +})); diff --git a/test/__fixtures__/references/special-types.test.ts b/test/__fixtures__/references/special-types.test.ts new file mode 100644 index 0000000..cdf6f49 --- /dev/null +++ b/test/__fixtures__/references/special-types.test.ts @@ -0,0 +1,86 @@ +import { assert, test } from 'poku'; +import { resource } from '../../../src/index.js'; +import { SpecialTypesMutatorContext } from './resource.js'; + +test('Date mutation via IPC', async () => { + const mutator = await resource.use(SpecialTypesMutatorContext); + const d = new Date('2026-01-15T00:00:00.000Z'); + + await mutator.mutateDate(d); + + assert.strictEqual( + d.getFullYear(), + 2000, + 'Date year should be updated to 2000 in place' + ); +}); + +test('Map mutation via IPC', async () => { + const mutator = await resource.use(SpecialTypesMutatorContext); + const m = new Map([ + ['keep', 1], + ['toRemove', 2], + ]); + + await mutator.mutateMap(m); + + assert.strictEqual(m.get('added'), 99, 'New entry should be present'); + assert.strictEqual( + m.has('toRemove'), + false, + 'Removed entry should be absent' + ); + assert.strictEqual(m.get('keep'), 1, 'Unchanged entry should remain'); +}); + +test('Set mutation via IPC', async () => { + const mutator = await resource.use(SpecialTypesMutatorContext); + const s = new Set([0, 1, 2]); + + await mutator.mutateSet(s); + + assert.strictEqual(s.has(99), true, 'New element should be present'); + assert.strictEqual(s.has(0), false, 'Removed element should be absent'); + assert.strictEqual(s.has(1), true, 'Unchanged element should remain'); +}); + +test('Setting object property to undefined via IPC', async () => { + const mutator = await resource.use(SpecialTypesMutatorContext); + const obj: Record = { a: 42 }; + + await mutator.setPropertyToUndefined(obj); + + assert.strictEqual('a' in obj, true, 'Key should still exist on the object'); + assert.strictEqual(obj.a, undefined, 'Value should be undefined'); +}); + +test('Pushing undefined into array via IPC', async () => { + const mutator = await resource.use(SpecialTypesMutatorContext); + const arr: (number | undefined)[] = [1, 2]; + + await mutator.pushUndefined(arr); + + assert.strictEqual(arr.length, 3, 'Array should have grown by one'); + assert.strictEqual(arr[2], undefined, 'New element should be undefined'); +}); + +test('BigInt array mutation via IPC', async () => { + const mutator = await resource.use(SpecialTypesMutatorContext); + const arr: bigint[] = [1n, 2n]; + + await mutator.mutateBigIntArray(arr); + + assert.strictEqual(arr.length, 3, 'Array should have grown by one'); + assert.strictEqual(arr[2], 99n, 'New BigInt element should be 99n'); +}); + +test('BigInt Map mutation via IPC', async () => { + const mutator = await resource.use(SpecialTypesMutatorContext); + const m = new Map([['keep', 1n], ['toRemove', 2n]]); + + await mutator.mutateBigIntMap(m); + + assert.strictEqual(m.has('toRemove'), false, 'Removed entry should be absent'); + assert.strictEqual(m.get('added'), 42n, 'New BigInt entry should be 42n'); + assert.strictEqual(m.get('keep'), 1n, 'Unchanged entry should remain'); +}); diff --git a/test/unit/encode-decode.test.ts b/test/unit/encode-decode.test.ts new file mode 100644 index 0000000..72a8740 --- /dev/null +++ b/test/unit/encode-decode.test.ts @@ -0,0 +1,216 @@ +import { assert, test } from 'poku'; +import { decodeArg, encodeArg } from '../../src/shared-resources.js'; + +const roundtrip = (v: unknown) => decodeArg(encodeArg(v)); + +test('encodeArg/decodeArg — undefined', () => { + assert.strictEqual( + roundtrip(undefined), + undefined, + 'undefined survives roundtrip' + ); +}); + +test('encodeArg/decodeArg — null', () => { + assert.strictEqual(roundtrip(null), null, 'null survives roundtrip'); +}); + +test('encodeArg/decodeArg — number', () => { + assert.strictEqual(roundtrip(42), 42, 'number survives roundtrip'); +}); + +test('encodeArg/decodeArg — string', () => { + assert.strictEqual(roundtrip('hello'), 'hello', 'string survives roundtrip'); +}); + +test('encodeArg/decodeArg — boolean', () => { + assert.strictEqual(roundtrip(true), true, 'boolean survives roundtrip'); +}); + +test('encodeArg/decodeArg — Date', () => { + const d = new Date('2026-03-17T12:00:00.000Z'); + const result = roundtrip(d); + assert.ok(result instanceof Date, 'Should be a Date instance'); + assert.strictEqual( + (result as Date).getTime(), + d.getTime(), + 'Date time preserved' + ); +}); + +test('encodeArg/decodeArg — Map', () => { + const m = new Map([ + ['a', 1], + ['b', 2], + ]); + const result = roundtrip(m) as Map; + assert.ok(result instanceof Map, 'Should be a Map instance'); + assert.strictEqual(result.get('a'), 1, 'Map entry a preserved'); + assert.strictEqual(result.get('b'), 2, 'Map entry b preserved'); + assert.strictEqual(result.size, 2, 'Map size preserved'); +}); + +test('encodeArg/decodeArg — Set', () => { + const s = new Set([1, 2, 3]); + const result = roundtrip(s) as Set; + assert.ok(result instanceof Set, 'Should be a Set instance'); + assert.strictEqual(result.has(1), true, 'Set element 1 preserved'); + assert.strictEqual(result.has(3), true, 'Set element 3 preserved'); + assert.strictEqual(result.size, 3, 'Set size preserved'); +}); + +test('encodeArg/decodeArg — array with undefined', () => { + const arr = [1, undefined, 3]; + const result = roundtrip(arr) as unknown[]; + assert.strictEqual(result[0], 1, 'First element preserved'); + assert.strictEqual(result[1], undefined, 'undefined element preserved'); + assert.strictEqual(result[2], 3, 'Third element preserved'); + assert.strictEqual(result.length, 3, 'Array length preserved'); +}); + +test('encodeArg/decodeArg — object with undefined value', () => { + const obj = { a: undefined as unknown, b: 1 }; + const result = roundtrip(obj) as Record; + assert.strictEqual( + 'a' in result, + true, + 'Key with undefined value should exist' + ); + assert.strictEqual(result.a, undefined, 'undefined value preserved'); + assert.strictEqual(result.b, 1, 'Other values preserved'); +}); + +test('encodeArg/decodeArg — nested Map inside object', () => { + const m = new Map([['x', 10]]); + const obj = { m, count: 1 }; + const result = roundtrip(obj) as { m: Map; count: number }; + assert.ok(result.m instanceof Map, 'Nested Map is a Map instance'); + assert.strictEqual(result.m.get('x'), 10, 'Nested Map entry preserved'); + assert.strictEqual(result.count, 1, 'Other object property preserved'); +}); + +test('encodeArg/decodeArg — nested Set inside array', () => { + const s = new Set([7, 8]); + const arr = [s, 42]; + const result = roundtrip(arr) as unknown[]; + assert.ok(result[0] instanceof Set, 'Nested Set is a Set instance'); + assert.strictEqual( + (result[0] as Set).has(7), + true, + 'Set element 7 preserved' + ); + assert.strictEqual(result[1], 42, 'Other array element preserved'); +}); + +test('encodeArg/decodeArg — nested Date inside array', () => { + const d = new Date('2026-01-01T00:00:00.000Z'); + const arr = [d, 42]; + const result = roundtrip(arr) as unknown[]; + assert.ok(result[0] instanceof Date, 'Nested Date is a Date instance'); + assert.strictEqual( + (result[0] as Date).getTime(), + d.getTime(), + 'Date value preserved' + ); + assert.strictEqual(result[1], 42, 'Other array element preserved'); +}); + +test('encodeArg/decodeArg — Map with object keys', () => { + const key = { id: 1 }; + const m = new Map([[key, 'value']]); + const result = roundtrip(m) as Map, string>; + assert.strictEqual(result.size, 1, 'Map size preserved'); + const [[rk, rv]] = result.entries(); + assert.deepStrictEqual(rk, key, 'Map object key preserved by value'); + assert.strictEqual(rv, 'value', 'Map value preserved'); +}); + +test('encodeArg/decodeArg — Map with undefined value', () => { + const m = new Map([['a', undefined]]); + const result = roundtrip(m) as Map; + assert.strictEqual(result.has('a'), true, 'Key with undefined value present'); + assert.strictEqual( + result.get('a'), + undefined, + 'undefined Map value preserved' + ); +}); + +test('encodeArg/decodeArg — Set with undefined', () => { + const s = new Set([1, undefined, 3]); + const result = roundtrip(s) as Set; + assert.strictEqual( + result.has(undefined), + true, + 'undefined Set element preserved' + ); + assert.strictEqual(result.size, 3, 'Set size preserved'); +}); + +test('encodeArg/decodeArg — plain object passthrough', () => { + const obj = { a: 1, b: 'hello', c: true }; + const result = roundtrip(obj); + assert.deepStrictEqual(result, obj, 'Plain object survives roundtrip'); +}); + +test('encodeArg/decodeArg — object with __sr_enc key (collision escape)', () => { + const obj = { __sr_enc: 'user-data', other: 42 }; + const result = roundtrip(obj) as typeof obj; + assert.strictEqual(result.__sr_enc, 'user-data', '__sr_enc key preserved'); + assert.strictEqual(result.other, 42, 'Other key preserved'); +}); + +test('encodeArg/decodeArg — BigInt', () => { + assert.strictEqual(roundtrip(42n), 42n, 'BigInt survives roundtrip'); + assert.strictEqual(roundtrip(0n), 0n, 'BigInt 0 survives roundtrip'); + assert.strictEqual(roundtrip(-99n), -99n, 'Negative BigInt survives roundtrip'); +}); + +test('encodeArg/decodeArg — BigInt in array', () => { + const result = roundtrip([1n, 2n, 3n]) as bigint[]; + assert.deepStrictEqual(result, [1n, 2n, 3n], 'Array of BigInts survives roundtrip'); +}); + +test('encodeArg/decodeArg — BigInt in object', () => { + const result = roundtrip({ a: 1n, b: 2 }) as { a: bigint; b: number }; + assert.strictEqual(result.a, 1n, 'BigInt property survives roundtrip'); + assert.strictEqual(result.b, 2, 'Non-BigInt property preserved'); +}); + +test('encodeArg/decodeArg — BigInt as Map key and value', () => { + const m = new Map([[1n, 100n], [2n, 200n]]); + const result = roundtrip(m) as Map; + assert.ok(result instanceof Map, 'Should be a Map instance'); + assert.strictEqual(result.get(1n), 100n, 'BigInt key and value preserved'); + assert.strictEqual(result.get(2n), 200n, 'Second BigInt entry preserved'); +}); + +test('encodeArg/decodeArg — BigInt in Set', () => { + const s = new Set([1n, 2n, 3n]); + const result = roundtrip(s) as Set; + assert.ok(result instanceof Set, 'Should be a Set instance'); + assert.strictEqual(result.has(1n), true, 'BigInt Set element preserved'); + assert.strictEqual(result.has(3n), true, 'BigInt Set element preserved'); +}); + +test('encodeArg/decodeArg — deeply nested special types', () => { + const d = new Date('2026-06-01T00:00:00.000Z'); + const m = new Map([['created', d]]); + const obj = { data: m, tags: new Set(['a', 'b']) }; + const result = roundtrip(obj) as { + data: Map; + tags: Set; + }; + assert.ok(result.data instanceof Map, 'Nested Map preserved'); + assert.ok( + result.data.get('created') instanceof Date, + 'Map-nested Date preserved' + ); + assert.strictEqual( + result.data.get('created')?.getTime(), + d.getTime(), + 'Date value correct' + ); + assert.ok(result.tags instanceof Set, 'Nested Set preserved'); + assert.strictEqual(result.tags.has('a'), true, 'Set element preserved'); +}); diff --git a/test/unit/write-back.test.ts b/test/unit/write-back.test.ts index 67736e0..0dc9b33 100644 --- a/test/unit/write-back.test.ts +++ b/test/unit/write-back.test.ts @@ -172,3 +172,115 @@ test('writeBack — mismatched types (array vs object) is a no-op', () => { 'Mismatched type should leave original unchanged' ); }); + +test('writeBack — Map repopulation', () => { + const original = new Map([ + ['a', 1], + ['b', 2], + ]); + const ref = original; + writeBack( + original, + new Map([ + ['b', 99], + ['c', 3], + ]) + ); + assert.strictEqual(original, ref, 'Map reference should be preserved'); + assert.strictEqual(original.has('a'), false, 'Removed key should be gone'); + assert.strictEqual(original.get('b'), 99, 'Updated key should be updated'); + assert.strictEqual(original.get('c'), 3, 'New key should be added'); +}); + +test('writeBack — Set repopulation', () => { + const original = new Set([1, 2, 3]); + const ref = original; + writeBack(original, new Set([2, 3, 4])); + assert.strictEqual(original, ref, 'Set reference should be preserved'); + assert.strictEqual(original.has(1), false, 'Removed element should be gone'); + assert.strictEqual(original.has(4), true, 'New element should be present'); + assert.strictEqual(original.has(2), true, 'Unchanged element should remain'); +}); + +test('writeBack — Date mutation', () => { + const original = new Date('2026-01-01T00:00:00.000Z'); + const ref = original; + writeBack(original, new Date('2000-06-15T00:00:00.000Z')); + assert.strictEqual(original, ref, 'Date reference should be preserved'); + assert.strictEqual(original.getFullYear(), 2000, 'Year should be updated'); + assert.strictEqual( + original.getMonth(), + 5, + 'Month should be updated (0-indexed)' + ); +}); + +test('writeBack — Map nested in object (reference preserved)', () => { + const innerMap = new Map([['x', 1]]); + const original: Record = { m: innerMap }; + writeBack(original, { + m: new Map([ + ['x', 1], + ['y', 2], + ]), + }); + assert.strictEqual( + original.m, + innerMap, + 'Inner Map reference should be preserved' + ); + assert.strictEqual( + innerMap.get('y'), + 2, + 'Inner Map should be updated with new entry' + ); +}); + +test('writeBack — Set nested in array (reference preserved)', () => { + const innerSet = new Set([1, 2]); + const original: unknown[] = [innerSet, 42]; + writeBack(original, [new Set([1, 2, 3]), 42]); + assert.strictEqual( + original[0], + innerSet, + 'Set reference inside array should be preserved' + ); + assert.strictEqual(innerSet.has(3), true, 'Set should have new element'); +}); + +test('writeBack — Date nested in array (reference preserved)', () => { + const innerDate = new Date('2026-01-01T00:00:00.000Z'); + const mutationTarget = new Date('2000-01-01T00:00:00.000Z'); + const original: unknown[] = [innerDate, 42]; + writeBack(original, [mutationTarget, 42]); + assert.strictEqual( + original[0], + innerDate, + 'Date reference inside array should be preserved' + ); + assert.strictEqual( + (original[0] as Date).getTime(), + mutationTarget.getTime(), + 'Date time should be updated' + ); +}); + +test('writeBack — Map is not treated as plain object (mismatched with plain object is no-op)', () => { + const original = new Map([['a', 1]]); + writeBack(original, { a: 2 }); + assert.strictEqual( + original.get('a'), + 1, + 'Map should be untouched when mutated is a plain object' + ); +}); + +test('writeBack — Set is not treated as plain object (mismatched with array is no-op)', () => { + const original = new Set([1, 2]); + writeBack(original, [3, 4]); + assert.strictEqual( + original.has(1), + true, + 'Set should be untouched when mutated is an array' + ); +}); From 2438748ddcc897a9535481d6e4871ecbedac6658 Mon Sep 17 00:00:00 2001 From: lojhan Date: Tue, 17 Mar 2026 17:13:27 -0300 Subject: [PATCH 3/3] test: new tests for mutation resource and special types handling --- .../references/special-types.test.ts | 11 +++++++++-- test/unit/encode-decode.test.ts | 17 ++++++++++++++--- 2 files changed, 23 insertions(+), 5 deletions(-) diff --git a/test/__fixtures__/references/special-types.test.ts b/test/__fixtures__/references/special-types.test.ts index cdf6f49..d1c3f0e 100644 --- a/test/__fixtures__/references/special-types.test.ts +++ b/test/__fixtures__/references/special-types.test.ts @@ -76,11 +76,18 @@ test('BigInt array mutation via IPC', async () => { test('BigInt Map mutation via IPC', async () => { const mutator = await resource.use(SpecialTypesMutatorContext); - const m = new Map([['keep', 1n], ['toRemove', 2n]]); + const m = new Map([ + ['keep', 1n], + ['toRemove', 2n], + ]); await mutator.mutateBigIntMap(m); - assert.strictEqual(m.has('toRemove'), false, 'Removed entry should be absent'); + assert.strictEqual( + m.has('toRemove'), + false, + 'Removed entry should be absent' + ); assert.strictEqual(m.get('added'), 42n, 'New BigInt entry should be 42n'); assert.strictEqual(m.get('keep'), 1n, 'Unchanged entry should remain'); }); diff --git a/test/unit/encode-decode.test.ts b/test/unit/encode-decode.test.ts index 72a8740..5b9771f 100644 --- a/test/unit/encode-decode.test.ts +++ b/test/unit/encode-decode.test.ts @@ -163,12 +163,20 @@ test('encodeArg/decodeArg — object with __sr_enc key (collision escape)', () = test('encodeArg/decodeArg — BigInt', () => { assert.strictEqual(roundtrip(42n), 42n, 'BigInt survives roundtrip'); assert.strictEqual(roundtrip(0n), 0n, 'BigInt 0 survives roundtrip'); - assert.strictEqual(roundtrip(-99n), -99n, 'Negative BigInt survives roundtrip'); + assert.strictEqual( + roundtrip(-99n), + -99n, + 'Negative BigInt survives roundtrip' + ); }); test('encodeArg/decodeArg — BigInt in array', () => { const result = roundtrip([1n, 2n, 3n]) as bigint[]; - assert.deepStrictEqual(result, [1n, 2n, 3n], 'Array of BigInts survives roundtrip'); + assert.deepStrictEqual( + result, + [1n, 2n, 3n], + 'Array of BigInts survives roundtrip' + ); }); test('encodeArg/decodeArg — BigInt in object', () => { @@ -178,7 +186,10 @@ test('encodeArg/decodeArg — BigInt in object', () => { }); test('encodeArg/decodeArg — BigInt as Map key and value', () => { - const m = new Map([[1n, 100n], [2n, 200n]]); + const m = new Map([ + [1n, 100n], + [2n, 200n], + ]); const result = roundtrip(m) as Map; assert.ok(result instanceof Map, 'Should be a Map instance'); assert.strictEqual(result.get(1n), 100n, 'BigInt key and value preserved');