diff --git a/frontend/package-lock.json b/frontend/package-lock.json index aade7bd2..adbfcc9e 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -23,6 +23,7 @@ "@types/jsdom": "^21.1.0", "@typescript-eslint/eslint-plugin": "^8.0.0", "@typescript-eslint/parser": "^8.0.0", + "@ungap/structured-clone": "^1.3.1", "babel-loader": "^9.1.0", "copy-webpack-plugin": "^14.0.0", "css-loader": "^6.8.0", @@ -3140,9 +3141,9 @@ } }, "node_modules/@ungap/structured-clone": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", - "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==", + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.1.tgz", + "integrity": "sha512-mUFwbeTqrVgDQxFveS+df2yfap6iuP20NAKAsBt5jDEoOTDew+zwLAOilHCeQJOVSvmgCX4ogqIrA0mnyr08yQ==", "dev": true, "license": "ISC" }, diff --git a/frontend/package.json b/frontend/package.json index fc809f76..f57d3ceb 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -24,6 +24,7 @@ "@types/jsdom": "^21.1.0", "@typescript-eslint/eslint-plugin": "^8.0.0", "@typescript-eslint/parser": "^8.0.0", + "@ungap/structured-clone": "^1.3.1", "babel-loader": "^9.1.0", "copy-webpack-plugin": "^14.0.0", "css-loader": "^6.8.0", diff --git a/frontend/src/__tests__/setup.ts b/frontend/src/__tests__/setup.ts index c5286012..329de724 100644 --- a/frontend/src/__tests__/setup.ts +++ b/frontend/src/__tests__/setup.ts @@ -4,6 +4,7 @@ import '@testing-library/jest-dom'; import { TextEncoder, TextDecoder } from 'util'; import { webcrypto } from 'crypto'; +import structuredClonePolyfill from '@ungap/structured-clone'; // Polyfill TextEncoder/TextDecoder for Node.js environment global.TextEncoder = TextEncoder; @@ -61,8 +62,24 @@ Object.defineProperty(global, 'Chart', { // the Node.js global structuredClone to the window scope). The utils.ts // deepClone function delegates to structuredClone; without this the suite // throws "ReferenceError: structuredClone is not defined" (finding 11-N1). +// +// Prefer the environment-provided native implementation when present; only +// polyfill when absent. The polyfill is @ungap/structured-clone, a faithful +// implementation of the HTML structured clone algorithm (preserves +// undefined-valued properties, Date, Map, Set, RegExp, cycles; throws +// TypeError on functions), unlike the previous JSON round-trip which +// validated weaker semantics than production browsers (TEST-07). When the +// host Node has a native structuredClone, @ungap delegates to it; otherwise +// it uses its own serialize/deserialize. In jsdom-on-jest, Date/Map/Set +// primordials are shared with Node so `instanceof` keeps working on cloned +// values regardless of which path runs. if (typeof globalThis.structuredClone === 'undefined') { - globalThis.structuredClone = (val: T): T => JSON.parse(JSON.stringify(val)) as T; + globalThis.structuredClone = ((value: T, options?: StructuredSerializeOptions): T => { + if (options?.transfer?.length) { + throw new Error('structuredClone polyfill does not support the transfer option'); + } + return structuredClonePolyfill(value); + }) as typeof structuredClone; } // Mock alert and confirm diff --git a/frontend/src/__tests__/ungap-structured-clone.d.ts b/frontend/src/__tests__/ungap-structured-clone.d.ts new file mode 100644 index 00000000..68b01294 --- /dev/null +++ b/frontend/src/__tests__/ungap-structured-clone.d.ts @@ -0,0 +1,12 @@ +// Type declarations for @ungap/structured-clone, which ships no .d.ts files. +// Used only by the Jest setup (setup.ts) to polyfill structuredClone with +// faithful HTML structured clone semantics under jest-environment-jsdom. +declare module '@ungap/structured-clone' { + interface StructuredCloneOptions { + transfer?: Transferable[]; + json?: boolean; + lossy?: boolean; + } + const structuredClonePolyfill: (value: T, options?: StructuredCloneOptions) => T; + export default structuredClonePolyfill; +} diff --git a/frontend/src/__tests__/utils.test.ts b/frontend/src/__tests__/utils.test.ts index 14d025d6..3c5f4418 100644 --- a/frontend/src/__tests__/utils.test.ts +++ b/frontend/src/__tests__/utils.test.ts @@ -501,10 +501,9 @@ describe('providerBadgeHtml', () => { // --------------------------------------------------------------------------- // Regression tests for finding 11-N1: deepClone defaults to structuredClone. -// Note: the jsdom test environment polyfills structuredClone with a JSON -// round-trip (see setup.ts) because jsdom does not expose Node's built-in -// structuredClone. As a result, undefined preservation can only be asserted -// when the native structuredClone is available (not in jsdom). +// The jsdom test environment polyfills structuredClone via +// `@ungap/structured-clone` (see setup.ts), which implements the real HTML +// structured clone algorithm, so full browser semantics are asserted here. // --------------------------------------------------------------------------- describe('deepClone (11-N1: structuredClone default)', () => { test('deep copy: mutation of clone does not affect source', () => { @@ -528,6 +527,57 @@ describe('deepClone (11-N1: structuredClone default)', () => { }); }); +// --------------------------------------------------------------------------- +// Regression tests for TEST-07: the test-environment structuredClone must +// implement real structured clone semantics, not a JSON round-trip. Each test +// below fails against the previous JSON.parse(JSON.stringify(...)) polyfill +// and passes against the browser/Node algorithm. +// --------------------------------------------------------------------------- +describe('deepClone (TEST-07: real structured clone semantics)', () => { + test('preserves undefined-valued properties (JSON round-trip drops them)', () => { + const obj: { a: number; b: number | undefined } = { a: 1, b: undefined }; + const clone = deepClone(obj); + expect('b' in clone).toBe(true); + expect(clone.b).toBeUndefined(); + }); + + test('preserves Date instances (JSON round-trip stringifies them)', () => { + const obj = { ts: new Date('2026-01-02T03:04:05.678Z') }; + const clone = deepClone(obj); + expect(clone.ts).toBeInstanceOf(Date); + expect(clone.ts.getTime()).toBe(obj.ts.getTime()); + expect(clone.ts).not.toBe(obj.ts); + }); + + test('preserves Map and Set (JSON round-trip degrades them to {})', () => { + const obj = { m: new Map([['k', 1]]), s: new Set([1, 2]) }; + const clone = deepClone(obj); + expect(clone.m).toBeInstanceOf(Map); + expect(clone.m.get('k')).toBe(1); + expect(clone.m).not.toBe(obj.m); + expect(clone.s).toBeInstanceOf(Set); + expect(clone.s.has(2)).toBe(true); + expect(clone.s).not.toBe(obj.s); + }); + + test('handles cyclic references (JSON round-trip throws)', () => { + interface Cyclic { + name: string; + self?: Cyclic; + } + const obj: Cyclic = { name: 'loop' }; + obj.self = obj; + const clone = deepClone(obj); + expect(clone).not.toBe(obj); + expect(clone.self).toBe(clone); + }); + + test('throws on functions like the browser implementation', () => { + // JSON round-trip silently drops functions; structured clone must throw. + expect(() => deepClone({ fn: (): number => 1 })).toThrow(); + }); +}); + describe('jsonClone (explicit JSON-serialisation variant)', () => { test('drops undefined values (expected JSON behaviour)', () => { const obj = { a: 1, b: undefined };