Skip to content

Commit 7ff6080

Browse files
authored
fix(core): add unit tests for stableStringify (google-gemini#27212)
1 parent 055e0f6 commit 7ff6080

2 files changed

Lines changed: 195 additions & 6 deletions

File tree

Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
1+
/**
2+
* @license
3+
* Copyright 2025 Google LLC
4+
* SPDX-License-Identifier: Apache-2.0
5+
*/
6+
7+
import { describe, expect, it } from 'vitest';
8+
import { stableStringify } from './stable-stringify.js';
9+
10+
describe('stableStringify', () => {
11+
it('should stringify basic primitives', () => {
12+
expect(stableStringify(null)).toBe('null');
13+
expect(stableStringify(true)).toBe('true');
14+
expect(stableStringify(false)).toBe('false');
15+
expect(stableStringify(123)).toBe('123');
16+
expect(stableStringify('hello')).toBe('"hello"');
17+
});
18+
19+
it('should sort object keys alphabetically', () => {
20+
const obj1 = { b: 2, a: 1, c: 3 };
21+
const obj2 = { c: 3, b: 2, a: 1 };
22+
23+
// Note: Top-level properties are wrapped in \0
24+
const expected = '{\0"a":1\0,\0"b":2\0,\0"c":3\0}';
25+
expect(stableStringify(obj1)).toBe(expected);
26+
expect(stableStringify(obj2)).toBe(expected);
27+
});
28+
29+
it('should handle nested objects (only top-level gets \0)', () => {
30+
const obj = { b: { d: 4, c: 3 }, a: 1 };
31+
const expected = '{\0"a":1\0,\0"b":{"c":3,"d":4}\0}';
32+
expect(stableStringify(obj)).toBe(expected);
33+
});
34+
35+
it('should handle arrays', () => {
36+
const arr = [3, 1, 2];
37+
// Top-level arrays don't get \0 because they don't have "keys" in the same way objects do in this implementation
38+
expect(stableStringify(arr)).toBe('[3,1,2]');
39+
});
40+
41+
it('should handle nested arrays and objects', () => {
42+
const obj = {
43+
b: [{ y: 2, x: 1 }, 3],
44+
a: 1,
45+
};
46+
const expected = '{\0"a":1\0,\0"b":[{"x":1,"y":2},3]\0}';
47+
expect(stableStringify(obj)).toBe(expected);
48+
});
49+
50+
it('should handle circular references by replacing them with "[Circular]"', () => {
51+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
52+
const obj: any = { a: 1 };
53+
obj.self = obj;
54+
const expected = '{\0"a":1\0,\0"self":"[Circular]"\0}';
55+
expect(stableStringify(obj)).toBe(expected);
56+
});
57+
58+
it('should handle deep circular references', () => {
59+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
60+
const obj: any = { a: { b: {} } };
61+
obj.a.b.parent = obj.a;
62+
obj.root = obj;
63+
64+
// ancestors: {obj}
65+
// "a": stringify({b: ...}, {obj}, false)
66+
// ancestors: {obj, obj.a}
67+
// "b": stringify({parent: ...}, {obj, obj.a}, false)
68+
// ancestors: {obj, obj.a, obj.a.b}
69+
// "parent": ancestors.has(obj.a) -> "[Circular]"
70+
const expected =
71+
'{\0"a":{"b":{"parent":"[Circular]"}}\0,\0"root":"[Circular]"\0}';
72+
expect(stableStringify(obj)).toBe(expected);
73+
});
74+
75+
it('should correctly handle multiple references to the same non-circular object', () => {
76+
const shared = { x: 1 };
77+
const obj = { a: shared, b: shared };
78+
// This is NOT circular, so it should be stringified twice
79+
const expected = '{\0"a":{"x":1}\0,\0"b":{"x":1}\0}';
80+
expect(stableStringify(obj)).toBe(expected);
81+
});
82+
83+
it('should respect toJSON methods', () => {
84+
const obj = {
85+
a: 1,
86+
toJSON: () => ({ b: 2 }),
87+
};
88+
// stableStringify calls toJSON, then stringifies the result.
89+
// If it's top-level, it should still have \0 for the resulting object's keys.
90+
const expected = '{\0"b":2\0}';
91+
expect(stableStringify(obj)).toBe(expected);
92+
});
93+
94+
it('should handle toJSON that returns a primitive', () => {
95+
const obj = {
96+
toJSON: () => 'json-val',
97+
};
98+
expect(stableStringify(obj)).toBe('"json-val"');
99+
});
100+
101+
it('should handle toJSON that throws by treating it as a regular object', () => {
102+
const obj = {
103+
a: 1,
104+
toJSON: () => {
105+
throw new Error('fail');
106+
},
107+
};
108+
// It should skip toJSON and proceed to stringify the object
109+
// Wait, if it treats it as a regular object, it will try to stringify 'toJSON' property?
110+
// But 'toJSON' is a function, so it should be omitted in objects.
111+
const expected = '{\0"a":1\0}';
112+
expect(stableStringify(obj)).toBe(expected);
113+
});
114+
115+
it('should omit undefined and functions in objects', () => {
116+
const obj = {
117+
a: 1,
118+
b: undefined,
119+
c: () => {},
120+
d: 2,
121+
};
122+
const expected = '{\0"a":1\0,\0"d":2\0}';
123+
expect(stableStringify(obj)).toBe(expected);
124+
});
125+
126+
it('should convert undefined and functions to null in arrays', () => {
127+
const arr = [1, undefined, () => {}, 2];
128+
expect(stableStringify(arr)).toBe('[1,null,null,2]');
129+
});
130+
131+
it('should handle Symbols in arrays (should ideally be null like undefined)', () => {
132+
const arr = [1, Symbol('foo'), 2];
133+
// If it behaves like JSON.stringify, it should be [1,null,2]
134+
// Let's see what it actually does.
135+
expect(stableStringify(arr)).toBe('[1,null,2]');
136+
});
137+
138+
it('should handle top-level undefined and functions', () => {
139+
expect(stableStringify(undefined)).toBe('null');
140+
expect(stableStringify(() => {})).toBe('null');
141+
});
142+
143+
it('should handle empty objects and arrays', () => {
144+
expect(stableStringify({})).toBe('{}');
145+
expect(stableStringify([])).toBe('[]');
146+
});
147+
148+
it('should handle special characters in keys (they should be escaped by JSON.stringify)', () => {
149+
const obj = { 'key\0with\0null': 1 };
150+
// JSON.stringify handles escaping \0 to \u0000
151+
// So it should be {\0"key\u0000with\u0000null":1\0}
152+
const expected = '{\0"key\\u0000with\\u0000null":1\0}';
153+
expect(stableStringify(obj)).toBe(expected);
154+
});
155+
156+
it('should handle repeated non-circular objects at different levels', () => {
157+
const shared = { x: 1 };
158+
const obj = {
159+
a: shared,
160+
b: {
161+
c: shared,
162+
},
163+
};
164+
const expected = '{\0"a":{"x":1}\0,\0"b":{"c":{"x":1}}\0}';
165+
expect(stableStringify(obj)).toBe(expected);
166+
});
167+
168+
it('should handle Symbols (return "null" consistently with undefined)', () => {
169+
// JSON.stringify(Symbol('foo')) is undefined, but stableStringify returns 'null' for consistency and type safety
170+
expect(stableStringify(Symbol('foo'))).toBe('null');
171+
});
172+
173+
it('should omit Symbols in objects', () => {
174+
const obj = { a: 1, b: Symbol('foo') };
175+
expect(stableStringify(obj)).toBe('{\0"a":1\0}');
176+
});
177+
178+
it('should handle BigInt (JSON.stringify throws, so stableStringify will throw)', () => {
179+
expect(() => stableStringify(BigInt(123))).toThrow();
180+
});
181+
});

packages/core/src/policy/stable-stringify.ts

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -63,8 +63,8 @@ export function stableStringify(obj: unknown): string {
6363
isTopLevel = false,
6464
): string => {
6565
// Handle primitives and null
66-
if (currentObj === undefined) {
67-
return 'null'; // undefined in arrays becomes null in JSON
66+
if (currentObj === undefined || typeof currentObj === 'symbol') {
67+
return 'null'; // undefined and symbols in arrays become null in JSON
6868
}
6969
if (currentObj === null) {
7070
return 'null';
@@ -104,8 +104,12 @@ export function stableStringify(obj: unknown): string {
104104

105105
if (Array.isArray(currentObj)) {
106106
const items = currentObj.map((item) => {
107-
// undefined and functions in arrays become null
108-
if (item === undefined || typeof item === 'function') {
107+
// undefined, functions and symbols in arrays become null
108+
if (
109+
item === undefined ||
110+
typeof item === 'function' ||
111+
typeof item === 'symbol'
112+
) {
109113
return 'null';
110114
}
111115
return stringify(item, ancestors, false);
@@ -120,8 +124,12 @@ export function stableStringify(obj: unknown): string {
120124
for (const key of sortedKeys) {
121125
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
122126
const value = (currentObj as Record<string, unknown>)[key];
123-
// Skip undefined and function values in objects (per JSON spec)
124-
if (value !== undefined && typeof value !== 'function') {
127+
// Skip undefined, function and symbol values in objects (per JSON spec)
128+
if (
129+
value !== undefined &&
130+
typeof value !== 'function' &&
131+
typeof value !== 'symbol'
132+
) {
125133
let pairStr =
126134
JSON.stringify(key) + ':' + stringify(value, ancestors, false);
127135

0 commit comments

Comments
 (0)