Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions frontend/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
19 changes: 18 additions & 1 deletion frontend/src/__tests__/setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 = <T>(val: T): T => JSON.parse(JSON.stringify(val)) as T;
globalThis.structuredClone = (<T>(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
Expand Down
12 changes: 12 additions & 0 deletions frontend/src/__tests__/ungap-structured-clone.d.ts
Original file line number Diff line number Diff line change
@@ -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: <T>(value: T, options?: StructuredCloneOptions) => T;
export default structuredClonePolyfill;
}
58 changes: 54 additions & 4 deletions frontend/src/__tests__/utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand All @@ -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 };
Expand Down
Loading