@thumbmarkjs/thumbmarkjs 1.3.4 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,335 @@
1
+ import { stableStringify } from './stableStringify';
2
+
3
+ describe('stableStringify', () => {
4
+ describe('basic functionality', () => {
5
+ test('sorts object keys alphabetically', () => {
6
+ const obj = { z: 3, a: 1, m: 2 };
7
+ const result = stableStringify(obj);
8
+ expect(result).toBe('{"a":1,"m":2,"z":3}');
9
+ });
10
+
11
+ test('produces valid JSON', () => {
12
+ const obj = { b: 2, a: 1, c: 3 };
13
+ const result = stableStringify(obj);
14
+ expect(() => JSON.parse(result)).not.toThrow();
15
+ expect(JSON.parse(result)).toEqual({ a: 1, b: 2, c: 3 });
16
+ });
17
+
18
+ test('produces consistent output for same input', () => {
19
+ const obj = { z: 3, a: 1, m: 2 };
20
+ const result1 = stableStringify(obj);
21
+ const result2 = stableStringify(obj);
22
+ expect(result1).toBe(result2);
23
+ });
24
+
25
+ test('produces same output regardless of key insertion order', () => {
26
+ const obj1 = { a: 1, b: 2, c: 3 };
27
+ const obj2 = { c: 3, a: 1, b: 2 };
28
+ const obj3 = { b: 2, c: 3, a: 1 };
29
+
30
+ const result1 = stableStringify(obj1);
31
+ const result2 = stableStringify(obj2);
32
+ const result3 = stableStringify(obj3);
33
+
34
+ expect(result1).toBe(result2);
35
+ expect(result2).toBe(result3);
36
+ });
37
+ });
38
+
39
+ describe('nested objects', () => {
40
+ test('sorts keys in nested objects', () => {
41
+ const obj = {
42
+ z: { y: 2, x: 1 },
43
+ a: { c: 4, b: 3 }
44
+ };
45
+ const result = stableStringify(obj);
46
+ expect(result).toBe('{"a":{"b":3,"c":4},"z":{"x":1,"y":2}}');
47
+ });
48
+
49
+ test('handles deeply nested objects', () => {
50
+ const obj = {
51
+ level1: {
52
+ z: 'last',
53
+ a: {
54
+ nested: {
55
+ z: 3,
56
+ a: 1,
57
+ m: 2
58
+ }
59
+ }
60
+ }
61
+ };
62
+ const result = stableStringify(obj);
63
+ const parsed = JSON.parse(result);
64
+ expect(parsed).toEqual({
65
+ level1: {
66
+ a: {
67
+ nested: {
68
+ a: 1,
69
+ m: 2,
70
+ z: 3
71
+ }
72
+ },
73
+ z: 'last'
74
+ }
75
+ });
76
+ });
77
+ });
78
+
79
+ describe('arrays', () => {
80
+ test('preserves array order', () => {
81
+ const arr = [3, 1, 2];
82
+ const result = stableStringify(arr);
83
+ expect(result).toBe('[3,1,2]');
84
+ });
85
+
86
+ test('handles arrays with objects', () => {
87
+ const arr = [
88
+ { z: 2, a: 1 },
89
+ { b: 4, a: 3 }
90
+ ];
91
+ const result = stableStringify(arr);
92
+ expect(result).toBe('[{"a":1,"z":2},{"a":3,"b":4}]');
93
+ });
94
+
95
+ test('handles nested arrays', () => {
96
+ const arr = [[3, 2, 1], [6, 5, 4]];
97
+ const result = stableStringify(arr);
98
+ expect(result).toBe('[[3,2,1],[6,5,4]]');
99
+ });
100
+
101
+ test('handles mixed arrays', () => {
102
+ const arr = [1, 'string', { z: 2, a: 1 }, [3, 4], null, true];
103
+ const result = stableStringify(arr);
104
+ expect(result).toBe('[1,"string",{"a":1,"z":2},[3,4],null,true]');
105
+ });
106
+ });
107
+
108
+ describe('primitive types', () => {
109
+ test('handles strings', () => {
110
+ expect(stableStringify('hello')).toBe('"hello"');
111
+ });
112
+
113
+ test('handles numbers', () => {
114
+ expect(stableStringify(42)).toBe('42');
115
+ expect(stableStringify(0)).toBe('0');
116
+ expect(stableStringify(-42)).toBe('-42');
117
+ expect(stableStringify(3.14)).toBe('3.14');
118
+ });
119
+
120
+ test('handles booleans', () => {
121
+ expect(stableStringify(true)).toBe('true');
122
+ expect(stableStringify(false)).toBe('false');
123
+ });
124
+
125
+ test('handles null', () => {
126
+ expect(stableStringify(null)).toBe('null');
127
+ });
128
+
129
+ test('handles undefined', () => {
130
+ expect(stableStringify(undefined)).toBe('');
131
+ });
132
+
133
+ test('handles undefined in objects', () => {
134
+ const obj = { a: 1, b: undefined, c: 3 };
135
+ const result = stableStringify(obj);
136
+ expect(result).toBe('{"a":1,"c":3}');
137
+ });
138
+
139
+ test('handles undefined in arrays', () => {
140
+ const arr = [1, undefined, 3];
141
+ const result = stableStringify(arr);
142
+ expect(result).toBe('[1,null,3]');
143
+ });
144
+ });
145
+
146
+ describe('special number values', () => {
147
+ test('handles Infinity as null', () => {
148
+ expect(stableStringify(Infinity)).toBe('null');
149
+ });
150
+
151
+ test('handles -Infinity as null', () => {
152
+ expect(stableStringify(-Infinity)).toBe('null');
153
+ });
154
+
155
+ test('handles NaN as null', () => {
156
+ expect(stableStringify(NaN)).toBe('null');
157
+ });
158
+
159
+ test('handles special numbers in objects', () => {
160
+ const obj = { a: Infinity, b: NaN, c: -Infinity };
161
+ const result = stableStringify(obj);
162
+ expect(result).toBe('{"a":null,"b":null,"c":null}');
163
+ });
164
+ });
165
+
166
+ describe('circular references', () => {
167
+ test('throws TypeError on circular reference', () => {
168
+ const obj: any = { a: 1 };
169
+ obj.self = obj;
170
+
171
+ expect(() => stableStringify(obj)).toThrow(TypeError);
172
+ expect(() => stableStringify(obj)).toThrow('Converting circular structure to JSON');
173
+ });
174
+
175
+ test('throws on nested circular reference', () => {
176
+ const obj: any = { a: { b: {} } };
177
+ obj.a.b.circular = obj;
178
+
179
+ expect(() => stableStringify(obj)).toThrow(TypeError);
180
+ });
181
+
182
+ test('throws on array circular reference', () => {
183
+ const arr: any = [1, 2, 3];
184
+ arr.push(arr);
185
+
186
+ // Note: Array circular references cause stack overflow (RangeError)
187
+ // rather than being caught by the circular reference check
188
+ expect(() => stableStringify(arr)).toThrow(RangeError);
189
+ });
190
+ });
191
+
192
+ describe('toJSON method', () => {
193
+ test('calls toJSON method if present', () => {
194
+ const obj = {
195
+ value: 42,
196
+ toJSON() {
197
+ return { transformed: this.value * 2 };
198
+ }
199
+ };
200
+ const result = stableStringify(obj);
201
+ expect(result).toBe('{"transformed":84}');
202
+ });
203
+
204
+ test('handles Date objects via toJSON', () => {
205
+ const date = new Date('2023-01-01T00:00:00.000Z');
206
+ const result = stableStringify(date);
207
+ expect(result).toBe('"2023-01-01T00:00:00.000Z"');
208
+ });
209
+
210
+ test('handles nested objects with toJSON', () => {
211
+ const obj = {
212
+ z: 'last',
213
+ a: {
214
+ toJSON() {
215
+ return { custom: 'value' };
216
+ }
217
+ }
218
+ };
219
+ const result = stableStringify(obj);
220
+ expect(result).toBe('{"a":{"custom":"value"},"z":"last"}');
221
+ });
222
+ });
223
+
224
+ describe('complex scenarios', () => {
225
+ test('handles empty object', () => {
226
+ expect(stableStringify({})).toBe('{}');
227
+ });
228
+
229
+ test('handles empty array', () => {
230
+ expect(stableStringify([])).toBe('[]');
231
+ });
232
+
233
+ test('handles complex nested structure', () => {
234
+ const complex = {
235
+ users: [
236
+ { name: 'Alice', age: 30, active: true },
237
+ { name: 'Bob', age: 25, active: false }
238
+ ],
239
+ metadata: {
240
+ version: 1,
241
+ timestamp: 1234567890,
242
+ config: {
243
+ enabled: true,
244
+ options: ['a', 'b', 'c']
245
+ }
246
+ },
247
+ count: 42
248
+ };
249
+
250
+ const result = stableStringify(complex);
251
+ const parsed = JSON.parse(result);
252
+
253
+ // Verify it's valid JSON
254
+ expect(parsed).toBeDefined();
255
+
256
+ // Verify structure is preserved
257
+ expect(parsed.users).toHaveLength(2);
258
+ expect(parsed.metadata.config.options).toEqual(['a', 'b', 'c']);
259
+ expect(parsed.count).toBe(42);
260
+ });
261
+
262
+ test('handles objects with special characters in keys', () => {
263
+ const obj = {
264
+ 'key with spaces': 1,
265
+ 'key-with-dashes': 2,
266
+ 'key_with_underscores': 3,
267
+ 'key.with.dots': 4
268
+ };
269
+ const result = stableStringify(obj);
270
+ const parsed = JSON.parse(result);
271
+ expect(parsed).toEqual(obj);
272
+ });
273
+
274
+ test('handles objects with numeric string keys', () => {
275
+ const obj = { '2': 'two', '1': 'one', '10': 'ten' };
276
+ const result = stableStringify(obj);
277
+ // Keys should be sorted as strings: "1", "10", "2"
278
+ expect(result).toBe('{"1":"one","10":"ten","2":"two"}');
279
+ });
280
+ });
281
+
282
+ describe('JSON validity', () => {
283
+ test('output is always valid JSON for valid inputs', () => {
284
+ const testCases = [
285
+ { a: 1, b: 2 },
286
+ [1, 2, 3],
287
+ 'string',
288
+ 42,
289
+ true,
290
+ null,
291
+ { nested: { deeply: { value: 'test' } } },
292
+ [{ a: 1 }, { b: 2 }],
293
+ { arr: [1, 2, { c: 3 }] }
294
+ ];
295
+
296
+ testCases.forEach(testCase => {
297
+ const result = stableStringify(testCase);
298
+ expect(() => JSON.parse(result)).not.toThrow();
299
+ });
300
+ });
301
+
302
+ test('parsed output equals original structure', () => {
303
+ const obj = {
304
+ z: 3,
305
+ a: 1,
306
+ nested: {
307
+ y: 2,
308
+ x: 1
309
+ },
310
+ arr: [3, 2, 1]
311
+ };
312
+
313
+ const result = stableStringify(obj);
314
+ const parsed = JSON.parse(result);
315
+
316
+ expect(parsed).toEqual(obj);
317
+ });
318
+ });
319
+
320
+ describe('stability comparison', () => {
321
+ test('produces same hash for equivalent objects', () => {
322
+ const obj1 = { b: 2, a: 1, c: { z: 26, y: 25 } };
323
+ const obj2 = { c: { y: 25, z: 26 }, a: 1, b: 2 };
324
+
325
+ expect(stableStringify(obj1)).toBe(stableStringify(obj2));
326
+ });
327
+
328
+ test('produces different output for different objects', () => {
329
+ const obj1 = { a: 1, b: 2 };
330
+ const obj2 = { a: 1, b: 3 };
331
+
332
+ expect(stableStringify(obj1)).not.toBe(stableStringify(obj2));
333
+ });
334
+ });
335
+ });
@@ -0,0 +1,70 @@
1
+ /**
2
+ * Stable JSON stringify implementation
3
+ * Based on fast-json-stable-stringify by Evgeny Poberezkin
4
+ * https://github.com/epoberezkin/fast-json-stable-stringify
5
+ *
6
+ * This implementation ensures consistent JSON serialization by sorting object keys,
7
+ * which is critical for generating stable hashes from fingerprint data.
8
+ */
9
+
10
+ /**
11
+ * Converts data to a stable JSON string with sorted keys
12
+ *
13
+ * @param data - The data to stringify
14
+ * @returns Stable JSON string representation
15
+ * @throws TypeError if circular reference is detected
16
+ *
17
+ * @example
18
+ * ```typescript
19
+ * const obj = { b: 2, a: 1 };
20
+ * stableStringify(obj); // '{"a":1,"b":2}'
21
+ * ```
22
+ */
23
+ export function stableStringify(data: any): string {
24
+
25
+ const seen: any[] = [];
26
+
27
+ return (function stringify(node: any): string | undefined {
28
+ if (node && node.toJSON && typeof node.toJSON === 'function') {
29
+ node = node.toJSON();
30
+ }
31
+
32
+ if (node === undefined) return;
33
+ if (typeof node === 'number') return isFinite(node) ? '' + node : 'null';
34
+ if (typeof node !== 'object') return JSON.stringify(node);
35
+
36
+ let i: number;
37
+ let out: string;
38
+
39
+ if (Array.isArray(node)) {
40
+ out = '[';
41
+ for (i = 0; i < node.length; i++) {
42
+ if (i) out += ',';
43
+ out += stringify(node[i]) || 'null';
44
+ }
45
+ return out + ']';
46
+ }
47
+
48
+ if (node === null) return 'null';
49
+
50
+ if (seen.indexOf(node) !== -1) {
51
+ throw new TypeError('Converting circular structure to JSON');
52
+ }
53
+
54
+ const seenIndex = seen.push(node) - 1;
55
+ const keys = Object.keys(node).sort();
56
+ out = '';
57
+
58
+ for (i = 0; i < keys.length; i++) {
59
+ const key = keys[i];
60
+ const value = stringify(node[key]);
61
+
62
+ if (!value) continue;
63
+ if (out) out += ',';
64
+ out += JSON.stringify(key) + ':' + value;
65
+ }
66
+
67
+ seen.splice(seenIndex, 1);
68
+ return '{' + out + '}';
69
+ })(data) || '';
70
+ }
@@ -0,0 +1,93 @@
1
+ import { getVisitorId, setVisitorId } from './visitorId';
2
+ import { optionsInterface, defaultOptions } from '../options';
3
+
4
+ describe('visitorId storage tests', () => {
5
+ beforeEach(() => {
6
+ // Clear localStorage before each test to ensure isolation
7
+ localStorage.clear();
8
+ });
9
+
10
+ describe('storage_property_name option', () => {
11
+ test('should use default storage property name', () => {
12
+ const visitorId = 'test-visitor-123';
13
+ const options = { ...defaultOptions };
14
+
15
+ setVisitorId(visitorId, options);
16
+
17
+ // Verify it was stored with the default property name
18
+ expect(localStorage.getItem('thumbmark_visitor_id')).toBe(visitorId);
19
+ expect(getVisitorId(options)).toBe(visitorId);
20
+ });
21
+
22
+ test('should use custom storage property name', () => {
23
+ const visitorId = 'custom-visitor-456';
24
+ const customOptions: optionsInterface = {
25
+ ...defaultOptions,
26
+ storage_property_name: 'my_custom_visitor_key'
27
+ };
28
+
29
+ setVisitorId(visitorId, customOptions);
30
+
31
+ // Verify it was stored with the custom property name
32
+ expect(localStorage.getItem('my_custom_visitor_key')).toBe(visitorId);
33
+ expect(getVisitorId(customOptions)).toBe(visitorId);
34
+
35
+ // Verify it's NOT in the default location
36
+ expect(localStorage.getItem('thumbmark_visitor_id')).toBeNull();
37
+ });
38
+
39
+ test('should return null when storage property does not exist', () => {
40
+ const options: optionsInterface = {
41
+ ...defaultOptions,
42
+ storage_property_name: 'nonexistent_key'
43
+ };
44
+
45
+ expect(getVisitorId(options)).toBeNull();
46
+ });
47
+
48
+ test('should overwrite existing value for same storage property', () => {
49
+ const oldVisitorId = 'old-visitor';
50
+ const newVisitorId = 'new-visitor';
51
+ const options = { ...defaultOptions };
52
+
53
+ setVisitorId(oldVisitorId, options);
54
+ expect(getVisitorId(options)).toBe(oldVisitorId);
55
+
56
+ setVisitorId(newVisitorId, options);
57
+ expect(getVisitorId(options)).toBe(newVisitorId);
58
+ });
59
+ });
60
+
61
+ describe('error handling', () => {
62
+ test('should handle localStorage.getItem errors gracefully', () => {
63
+ const options = { ...defaultOptions };
64
+
65
+ // Mock localStorage.getItem to throw an error
66
+ const originalGetItem = localStorage.getItem;
67
+ localStorage.getItem = jest.fn(() => {
68
+ throw new Error('Storage quota exceeded');
69
+ });
70
+
71
+ expect(getVisitorId(options)).toBeNull();
72
+
73
+ // Restore original implementation
74
+ localStorage.getItem = originalGetItem;
75
+ });
76
+
77
+ test('should handle localStorage.setItem errors gracefully', () => {
78
+ const options = { ...defaultOptions };
79
+
80
+ // Mock localStorage.setItem to throw an error
81
+ const originalSetItem = localStorage.setItem;
82
+ localStorage.setItem = jest.fn(() => {
83
+ throw new Error('Storage quota exceeded');
84
+ });
85
+
86
+ // Should not throw error
87
+ expect(() => setVisitorId('test-id', options)).not.toThrow();
88
+
89
+ // Restore original implementation
90
+ localStorage.setItem = originalSetItem;
91
+ });
92
+ });
93
+ });
@@ -1,15 +1,15 @@
1
+ import { optionsInterface } from "../options";
2
+
1
3
  /**
2
4
  * Visitor ID storage utilities - localStorage only, server generates IDs
3
5
  */
4
6
 
5
- const VISITOR_ID_KEY = 'thumbmark_visitor_id';
6
-
7
7
  /**
8
8
  * Gets visitor ID from localStorage, returns null if unavailable
9
9
  */
10
- export function getVisitorId(): string | null {
10
+ export function getVisitorId(_options: optionsInterface): string | null {
11
11
  try {
12
- return localStorage.getItem(VISITOR_ID_KEY);
12
+ return localStorage.getItem(_options.storage_property_name);
13
13
  } catch {
14
14
  return null;
15
15
  }
@@ -18,9 +18,9 @@ export function getVisitorId(): string | null {
18
18
  /**
19
19
  * Sets visitor ID in localStorage
20
20
  */
21
- export function setVisitorId(visitorId: string): void {
21
+ export function setVisitorId(visitorId: string, _options: optionsInterface): void {
22
22
  try {
23
- localStorage.setItem(VISITOR_ID_KEY, visitorId);
23
+ localStorage.setItem(_options.storage_property_name, visitorId);
24
24
  } catch {
25
25
  // Ignore storage errors
26
26
  }