@thumbmarkjs/thumbmarkjs 1.9.0 → 1.10.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.
Files changed (68) hide show
  1. package/README.md +96 -102
  2. package/dist/thumbmark.cjs.js +1 -1
  3. package/dist/thumbmark.cjs.js.map +1 -1
  4. package/dist/thumbmark.esm.d.ts +6 -0
  5. package/dist/thumbmark.esm.js +1 -1
  6. package/dist/thumbmark.esm.js.map +1 -1
  7. package/dist/thumbmark.umd.js +1 -1
  8. package/dist/thumbmark.umd.js.map +1 -1
  9. package/dist/types/components/audio/index.d.ts +2 -0
  10. package/dist/types/components/canvas/index.d.ts +3 -0
  11. package/dist/types/components/fonts/index.d.ts +4 -0
  12. package/dist/types/components/hardware/index.d.ts +2 -0
  13. package/dist/types/components/locales/index.d.ts +2 -0
  14. package/dist/types/components/math/index.d.ts +2 -0
  15. package/dist/types/components/mathml/index.d.ts +2 -0
  16. package/dist/types/components/permissions/index.d.ts +3 -0
  17. package/dist/types/components/plugins/index.d.ts +2 -0
  18. package/dist/types/components/screen/index.d.ts +2 -0
  19. package/dist/types/components/speech/index.d.ts +2 -0
  20. package/dist/types/components/system/browser.d.ts +9 -0
  21. package/dist/types/components/system/index.d.ts +2 -0
  22. package/dist/types/components/webgl/index.d.ts +13 -0
  23. package/dist/types/components/webrtc/index.d.ts +3 -0
  24. package/dist/types/factory.d.ts +62 -0
  25. package/dist/types/functions/api.d.ts +73 -0
  26. package/dist/types/functions/filterComponents.d.ts +15 -0
  27. package/dist/types/functions/index.d.ts +63 -0
  28. package/dist/types/functions/legacy_functions.d.ts +27 -0
  29. package/dist/types/index.d.ts +9 -0
  30. package/dist/types/options.d.ts +82 -0
  31. package/dist/types/thumbmark.d.ts +28 -0
  32. package/dist/types/utils/cache.d.ts +23 -0
  33. package/dist/types/utils/commonPixels.d.ts +1 -0
  34. package/dist/types/utils/ephemeralIFrame.d.ts +4 -0
  35. package/dist/types/utils/getMostFrequent.d.ts +5 -0
  36. package/dist/types/utils/hash.d.ts +5 -0
  37. package/dist/types/utils/imageDataToDataURL.d.ts +1 -0
  38. package/dist/types/utils/log.d.ts +9 -0
  39. package/dist/types/utils/raceAll.d.ts +10 -0
  40. package/dist/types/utils/sort.d.ts +8 -0
  41. package/dist/types/utils/stableStringify.d.ts +22 -0
  42. package/dist/types/utils/version.d.ts +4 -0
  43. package/dist/types/utils/visitorId.d.ts +14 -0
  44. package/package.json +102 -70
  45. package/src/factory.ts +0 -4
  46. package/src/functions/api.ts +19 -6
  47. package/src/functions/filterComponents.ts +2 -3
  48. package/src/functions/index.ts +1 -1
  49. package/src/options.ts +7 -0
  50. package/src/components/canvas/index.test.ts +0 -38
  51. package/src/components/hardware/index.test.ts +0 -80
  52. package/src/components/intl/index.test.ts +0 -124
  53. package/src/components/intl/index.ts +0 -41
  54. package/src/components/mediaDevices/index.test.ts +0 -120
  55. package/src/components/mediaDevices/index.ts +0 -26
  56. package/src/components/system/browser.test.ts +0 -108
  57. package/src/components/webgl/index.test.ts +0 -223
  58. package/src/functions/api.test.ts +0 -366
  59. package/src/functions/filterComponents.test.ts +0 -238
  60. package/src/functions/functions.test.ts +0 -142
  61. package/src/functions/metadata.test.ts +0 -211
  62. package/src/options.test.ts +0 -10
  63. package/src/thumbmark.custom-components.test.ts +0 -87
  64. package/src/thumbmark.test.ts +0 -59
  65. package/src/utils/cache.test.ts +0 -95
  66. package/src/utils/raceAll.test.ts +0 -86
  67. package/src/utils/stableStringify.test.ts +0 -335
  68. package/src/utils/visitorId.test.ts +0 -102
@@ -1,366 +0,0 @@
1
- import { apiResponse, getCachedApiResponse, setCachedApiResponse, getApiPromise } from "./api";
2
- import { defaultOptions } from "../options";
3
- import { getCache, setCache } from "../utils/cache";
4
- import { setVisitorId } from "../utils/visitorId";
5
- import type { componentInterface } from "../factory";
6
- import * as hashModule from "../utils/hash";
7
- import * as stringifyModule from "../utils/stableStringify";
8
-
9
- const options = {
10
- ...defaultOptions,
11
- cache_lifetime_in_ms: 100,
12
- }
13
-
14
- const apiResponse = {
15
- identifier: 'test'
16
- } as apiResponse;
17
-
18
- describe('setCachedApiResponse', () => {
19
- beforeEach(() => {
20
- localStorage.clear();
21
- })
22
-
23
- test('it should write the apiResponse to the cache if options allow that', () => {
24
- setCachedApiResponse(options, apiResponse);
25
- expect(getCache(options).apiResponse).toEqual(apiResponse);
26
- });
27
-
28
- test('it should not write if cache if off', () => {
29
- setCachedApiResponse({
30
- ...options,
31
- cache_api_call: false,
32
- }, apiResponse);
33
-
34
- expect(getCache(options).apiResponse).not.toBeDefined();
35
- expect(getCache(options).apiResponseExpiry).not.toBeDefined();
36
- });
37
-
38
- test('it should not write if lifetime is 0', () => {
39
- setCachedApiResponse({
40
- ...options,
41
- cache_lifetime_in_ms: 0
42
- }, apiResponse);
43
-
44
- expect(getCache(options).apiResponse).not.toBeDefined();
45
- expect(getCache(options).apiResponseExpiry).not.toBeDefined();
46
- });
47
- })
48
-
49
- describe('getCachedApiResponse', () => {
50
- beforeEach(() => {
51
- localStorage.clear();
52
- })
53
-
54
- test('it should get from the cache if a value exists there', () => {
55
- setCache(options, {
56
- apiResponseExpiry: Date.now() + 2000000,
57
- apiResponse,
58
- });
59
-
60
- const cached = getCachedApiResponse(options);
61
- expect(cached).toBeDefined();
62
- expect(cached).toEqual(apiResponse);
63
- });
64
-
65
- test('it should not return an expiried cache', () => {
66
- setCache(options, {
67
- apiResponseExpiry: Date.now() - 2000,
68
- apiResponse,
69
- });
70
-
71
- const cached = getCachedApiResponse(options);
72
- expect(cached).not.toBeDefined();
73
- })
74
-
75
- })
76
-
77
- describe('getApiPromise timeout behavior', () => {
78
- let mockFetch: jest.Mock;
79
- const testOptions = {
80
- ...defaultOptions,
81
- api_key: 'test-api-key',
82
- timeout: 100, // Short timeout for tests
83
- cache_lifetime_in_ms: 1000,
84
- };
85
-
86
- const testComponents: componentInterface = {
87
- userAgent: 'test-agent',
88
- screen: { width: 1920, height: 1080 },
89
- };
90
-
91
- beforeAll(() => {
92
- // Polyfill TextEncoder for jsdom environment
93
- if (typeof TextEncoder === 'undefined') {
94
- const util = require('util');
95
- global.TextEncoder = util.TextEncoder;
96
- global.TextDecoder = util.TextDecoder;
97
- }
98
-
99
- // Mock hash and stableStringify to avoid TextEncoder issues
100
- jest.spyOn(hashModule, 'hash').mockReturnValue('mocked-hash');
101
- jest.spyOn(stringifyModule, 'stableStringify').mockReturnValue('{"mocked":"data"}');
102
-
103
- jest.useFakeTimers();
104
- });
105
-
106
- afterAll(() => {
107
- jest.useRealTimers();
108
- });
109
-
110
- beforeEach(async () => {
111
- // Clear all pending timers and promises from previous tests
112
- jest.clearAllTimers();
113
-
114
- localStorage.clear();
115
- mockFetch = jest.fn();
116
- global.fetch = mockFetch;
117
-
118
- // Re-mock hash and stableStringify for each test
119
- jest.spyOn(hashModule, 'hash').mockReturnValue('mocked-hash');
120
- jest.spyOn(stringifyModule, 'stableStringify').mockReturnValue('{"mocked":"data"}');
121
- });
122
-
123
- afterEach(() => {
124
- jest.restoreAllMocks();
125
- });
126
-
127
- test('returns fetch response when fetch completes before timeout', async () => {
128
- // Use options without caching to avoid state interference
129
- const noCacheOptions = {
130
- ...testOptions,
131
- cache_lifetime_in_ms: 0, // Disable caching
132
- };
133
-
134
- // Mock fetch to resolve quickly
135
- mockFetch.mockResolvedValueOnce({
136
- ok: true,
137
- status: 200,
138
- json: async () => ({
139
- version: '1.2.3',
140
- visitorId: 'test-visitor-id',
141
- thumbmark: 'test-thumbmark',
142
- requestId: 'test-request-id',
143
- info: {
144
- ip_address: {
145
- ip_address: '1.2.3.4',
146
- ip_identifier: 'abc123',
147
- autonomous_system_number: 12345,
148
- ip_version: 'v4' as const,
149
- }
150
- }
151
- })
152
- });
153
-
154
- const promise = getApiPromise(noCacheOptions, testComponents);
155
-
156
- // Fast forward time, but fetch completes before timeout
157
- await jest.runAllTimersAsync();
158
- const result = await promise;
159
-
160
- expect(result).toBeDefined();
161
- expect(result?.version).toBe('1.2.3');
162
- expect(result?.info?.timed_out).toBeUndefined();
163
- expect(result?.thumbmark).toBe('test-thumbmark');
164
- expect(result?.requestId).toBe('test-request-id');
165
- });
166
-
167
- test('returns expired cache when timeout occurs and cache exists', async () => {
168
- // Disable caching to test timeout fallback without request deduplication
169
- const noCacheOptions = {
170
- ...testOptions,
171
- cache_api_call: false,
172
- };
173
-
174
- // Set up expired cache
175
- const expiredCachedValue = {
176
- apiResponseExpiry: Date.now() - 100, // Expired
177
- apiResponse: {
178
- version: '1.2.3',
179
- thumbmark: 'cached-thumbmark',
180
- info: {
181
- ip_address: {
182
- ip_address: '1.2.3.4',
183
- ip_identifier: 'abc123',
184
- autonomous_system_number: 12345,
185
- ip_version: 'v4' as const,
186
- }
187
- }
188
- }
189
- };
190
- setCache(noCacheOptions, expiredCachedValue);
191
-
192
- // Mock fetch to never resolve (hanging request)
193
- mockFetch.mockReturnValueOnce(new Promise(() => { }));
194
-
195
- const promise = getApiPromise(noCacheOptions, testComponents);
196
-
197
- // Advance timers to trigger timeout
198
- await jest.runAllTimersAsync();
199
- const result = await promise;
200
-
201
- // Should return expired cache, not timeout response
202
- expect(result).toBeDefined();
203
- expect(result?.version).toBe('1.2.3');
204
- expect(result?.thumbmark).toBe('cached-thumbmark');
205
- expect(result?.info?.timed_out).toBeUndefined();
206
- });
207
-
208
- test('returns timeout response when timeout occurs and no cache exists', async () => {
209
- const noCacheOptions = {
210
- ...testOptions,
211
- cache_api_call: false,
212
- };
213
-
214
- expect(getCache(noCacheOptions)).toEqual({});
215
-
216
- mockFetch.mockReturnValueOnce(new Promise(() => { }));
217
-
218
- const promise = getApiPromise(noCacheOptions, testComponents);
219
-
220
- await jest.runAllTimersAsync();
221
- const result = await promise;
222
-
223
- expect(result).toBeDefined();
224
- expect(result?.info?.timed_out).toBe(true);
225
-
226
- const cached = getCache(noCacheOptions);
227
- expect(cached.apiResponse).toBeUndefined();
228
- });
229
-
230
- test('returns timeout response without visitorId when no cache and no stored visitorId', async () => {
231
- const noCacheOptions = {
232
- ...testOptions,
233
- cache_api_call: false,
234
- };
235
-
236
- // Ensure no cache and no visitor ID
237
- expect(getCache(noCacheOptions)).toEqual({});
238
- expect(localStorage.getItem('thumbmark_visitor_id')).toBeNull();
239
-
240
- mockFetch.mockReturnValueOnce(new Promise(() => { }));
241
-
242
- const promise = getApiPromise(noCacheOptions, testComponents);
243
-
244
- await jest.runAllTimersAsync();
245
- const result = await promise;
246
-
247
- expect(result).toBeDefined();
248
- expect(result?.info?.timed_out).toBe(true);
249
- expect(result?.visitorId).toBeUndefined();
250
- });
251
-
252
- test('returns timeout response with visitorId when no cache but visitorId exists in localStorage', async () => {
253
- const noCacheOptions = {
254
- ...testOptions,
255
- cache_api_call: false,
256
- };
257
-
258
- // Set up visitor ID in localStorage (no cache)
259
- const storedVisitorId = 'stored-visitor-id-123';
260
- setVisitorId(storedVisitorId, noCacheOptions);
261
-
262
- // Verify setup: no cache, but visitor ID exists
263
- expect(getCache(noCacheOptions)).toEqual({});
264
- expect(localStorage.getItem('thumbmark_visitor_id')).toBe(storedVisitorId);
265
-
266
- mockFetch.mockReturnValueOnce(new Promise(() => { }));
267
-
268
- const promise = getApiPromise(noCacheOptions, testComponents);
269
-
270
- await jest.runAllTimersAsync();
271
- const result = await promise;
272
-
273
- expect(result).toBeDefined();
274
- expect(result?.info?.timed_out).toBe(true);
275
- expect(result?.visitorId).toBe(storedVisitorId);
276
- });
277
-
278
- test('retries on network error and succeeds on second attempt', async () => {
279
- jest.useRealTimers();
280
- const noCacheOptions = { ...testOptions, cache_api_call: false, timeout: 5000 };
281
-
282
- mockFetch
283
- .mockRejectedValueOnce(new TypeError('Failed to fetch'))
284
- .mockResolvedValueOnce({
285
- ok: true, status: 200,
286
- json: async () => ({ version: '1.0', thumbmark: 'retry-ok' }),
287
- });
288
-
289
- const result = await getApiPromise(noCacheOptions, testComponents);
290
-
291
- expect(mockFetch).toHaveBeenCalledTimes(2);
292
- expect(result?.thumbmark).toBe('retry-ok');
293
- jest.useFakeTimers();
294
- });
295
-
296
- test('does not retry on 500 server error', async () => {
297
- jest.useRealTimers();
298
- const noCacheOptions = { ...testOptions, cache_api_call: false, timeout: 5000 };
299
-
300
- mockFetch.mockResolvedValueOnce({ ok: false, status: 500 });
301
-
302
- await expect(getApiPromise(noCacheOptions, testComponents)).rejects.toThrow('HTTP error! status: 500');
303
- expect(mockFetch).toHaveBeenCalledTimes(1);
304
- jest.useFakeTimers();
305
- });
306
-
307
- test('does not retry on 403 auth error', async () => {
308
- jest.useRealTimers();
309
- const noCacheOptions = { ...testOptions, cache_api_call: false, timeout: 5000 };
310
-
311
- mockFetch.mockResolvedValueOnce({ ok: false, status: 403 });
312
-
313
- await expect(getApiPromise(noCacheOptions, testComponents)).rejects.toThrow('HTTP error! status: 403');
314
- expect(mockFetch).toHaveBeenCalledTimes(1);
315
- jest.useFakeTimers();
316
- });
317
-
318
- test('network errors exhaust retries then throw', async () => {
319
- jest.useRealTimers();
320
- const noCacheOptions = { ...testOptions, cache_api_call: false, timeout: 5000 };
321
-
322
- mockFetch.mockRejectedValue(new TypeError('Failed to fetch'));
323
-
324
- await expect(getApiPromise(noCacheOptions, testComponents)).rejects.toThrow('Failed to fetch');
325
- expect(mockFetch).toHaveBeenCalledTimes(3);
326
- jest.useFakeTimers();
327
- });
328
-
329
- test('returns full cached response (not just visitorId) when cache exists on timeout', async () => {
330
- const noCacheOptions = {
331
- ...testOptions,
332
- cache_api_call: false,
333
- };
334
-
335
- // Set up both: expired cache AND visitor ID in localStorage
336
- const storedVisitorId = 'stored-visitor-id-456';
337
- setVisitorId(storedVisitorId, noCacheOptions);
338
-
339
- const expiredCachedValue = {
340
- apiResponseExpiry: Date.now() - 100, // Expired
341
- apiResponse: {
342
- version: '1.2.3',
343
- thumbmark: 'cached-thumbmark',
344
- visitorId: 'cached-visitor-id',
345
- info: {
346
- uniqueness: { score: 0.95 }
347
- }
348
- }
349
- };
350
- setCache(noCacheOptions, expiredCachedValue);
351
-
352
- mockFetch.mockReturnValueOnce(new Promise(() => { }));
353
-
354
- const promise = getApiPromise(noCacheOptions, testComponents);
355
-
356
- await jest.runAllTimersAsync();
357
- const result = await promise;
358
-
359
- // Should return full cached response, not just visitor ID fallback
360
- expect(result).toBeDefined();
361
- expect(result?.info?.timed_out).toBeUndefined();
362
- expect(result?.thumbmark).toBe('cached-thumbmark');
363
- expect(result?.visitorId).toBe('cached-visitor-id'); // From cache, not localStorage
364
- expect(result?.version).toBe('1.2.3');
365
- });
366
- })
@@ -1,238 +0,0 @@
1
- import { getExcludeList, filterThumbmarkData } from './filterComponents';
2
- import { defaultOptions } from '../options';
3
- import { componentInterface } from '../factory';
4
-
5
- // Mock getBrowser so we can control browser detection in tests
6
- jest.mock('../components/system/browser', () => ({
7
- getBrowser: jest.fn(() => ({ name: 'unknown', version: 'unknown' })),
8
- }));
9
-
10
- import { getBrowser } from '../components/system/browser';
11
- const mockGetBrowser = getBrowser as jest.Mock;
12
-
13
- const testData: componentInterface = {
14
- one: '1',
15
- two: 2,
16
- three: { a: true, b: false },
17
- speech: { hash: 'abc' },
18
- canvas: { hash: 'def' },
19
- audio: { sampleHash: 'ghi', other: 'jkl' },
20
- };
21
-
22
- // ── getExcludeList ──────────────────────────────────────────────
23
-
24
- describe('getExcludeList', () => {
25
- beforeEach(() => {
26
- mockGetBrowser.mockReturnValue({ name: 'unknown', version: 'unknown' });
27
- });
28
-
29
- test('returns empty when no options and unknown browser', () => {
30
- expect(getExcludeList()).toEqual([]);
31
- });
32
-
33
- test('returns user exclude list as-is', () => {
34
- const result = getExcludeList({ ...defaultOptions, exclude: ['one', 'two'] });
35
- expect(result).toContain('one');
36
- expect(result).toContain('two');
37
- });
38
-
39
- test("'always' rules apply even with empty stabilize", () => {
40
- mockGetBrowser.mockReturnValue({ name: 'Firefox', version: '130.0' });
41
- const result = getExcludeList({ ...defaultOptions, stabilize: [] });
42
- expect(result).toContain('speech');
43
- });
44
-
45
- test("'always' rules don't apply when browser doesn't match", () => {
46
- mockGetBrowser.mockReturnValue({ name: 'Chrome', version: '120.0' });
47
- const result = getExcludeList({ ...defaultOptions, stabilize: [] });
48
- expect(result).not.toContain('speech');
49
- });
50
-
51
- test('stabilization rules expand for matching browser', () => {
52
- mockGetBrowser.mockReturnValue({ name: 'Firefox', version: '120.0' });
53
- const result = getExcludeList({ ...defaultOptions, stabilize: ['private'] });
54
- expect(result).toContain('canvas');
55
- expect(result).toContain('fonts');
56
- expect(result).toContain('tls.extensions');
57
- });
58
-
59
- test('stabilization rules do not apply for non-matching browser', () => {
60
- mockGetBrowser.mockReturnValue({ name: 'Chrome', version: '120.0' });
61
- const result = getExcludeList({ ...defaultOptions, stabilize: ['private'] });
62
- expect(result).not.toContain('canvas');
63
- expect(result).not.toContain('fonts');
64
- // Chrome does get some rules though
65
- expect(result).toContain('header.acceptLanguage');
66
- expect(result).toContain('tls.extensions');
67
- });
68
-
69
- test('version matching with >= syntax', () => {
70
- // safari>=17 should match Safari 18
71
- mockGetBrowser.mockReturnValue({ name: 'Safari', version: '18.0' });
72
- const result = getExcludeList({ ...defaultOptions, stabilize: ['private'] });
73
- expect(result).toContain('canvas');
74
-
75
- // safari>=17 should match Safari 17
76
- mockGetBrowser.mockReturnValue({ name: 'Safari', version: '17.0' });
77
- const result17 = getExcludeList({ ...defaultOptions, stabilize: ['private'] });
78
- expect(result17).toContain('canvas');
79
-
80
- // safari>=17 should NOT match Safari 16
81
- mockGetBrowser.mockReturnValue({ name: 'Safari', version: '16.5' });
82
- const result16 = getExcludeList({ ...defaultOptions, stabilize: ['private'] });
83
- expect(result16).not.toContain('canvas');
84
- });
85
-
86
- test('browser-independent rules always apply', () => {
87
- // 'iframe' has { exclude: ['permissions'] } with no browsers key
88
- mockGetBrowser.mockReturnValue({ name: 'Chrome', version: '120.0' });
89
- const result = getExcludeList({ ...defaultOptions, stabilize: ['iframe'] });
90
- expect(result).toContain('permissions');
91
- });
92
-
93
- test('vpn stabilization excludes ip for any browser', () => {
94
- mockGetBrowser.mockReturnValue({ name: 'Chrome', version: '120.0' });
95
- const result = getExcludeList({ ...defaultOptions, stabilize: ['vpn'] });
96
- expect(result).toContain('ip');
97
- });
98
-
99
- test('multiple stabilization options combine', () => {
100
- mockGetBrowser.mockReturnValue({ name: 'Firefox', version: '120.0' });
101
- const result = getExcludeList({ ...defaultOptions, stabilize: ['private', 'vpn'] });
102
- expect(result).toContain('canvas'); // from private+firefox
103
- expect(result).toContain('ip'); // from vpn
104
- expect(result).toContain('speech'); // from always+firefox
105
- });
106
-
107
- test('user exclude list is combined with stabilization rules', () => {
108
- mockGetBrowser.mockReturnValue({ name: 'Firefox', version: '120.0' });
109
- const result = getExcludeList({ ...defaultOptions, exclude: ['custom'], stabilize: ['private'] });
110
- expect(result).toContain('custom'); // user's
111
- expect(result).toContain('canvas'); // from rules
112
- });
113
-
114
- test('unknown stabilization option is silently skipped', () => {
115
- const result = getExcludeList({ ...defaultOptions, stabilize: ['nonexistent' as any] });
116
- // Should not throw, just return whatever 'always' contributes (nothing for unknown browser)
117
- expect(Array.isArray(result)).toBe(true);
118
- });
119
-
120
- test('browser fallback from component data (server-side)', () => {
121
- // getBrowser returns unknown (simulating server-side)
122
- mockGetBrowser.mockReturnValue({ name: 'unknown', version: 'unknown' });
123
-
124
- const obj: componentInterface = {
125
- system: {
126
- browser: {
127
- name: 'Brave',
128
- version: '130.0',
129
- },
130
- },
131
- };
132
-
133
- const result = getExcludeList({ ...defaultOptions, stabilize: ['private'] }, obj);
134
- // Brave-specific rules should apply via fallback
135
- expect(result).toContain('canvas');
136
- expect(result).toContain('plugins');
137
- expect(result).toContain('speech'); // from 'always'
138
- });
139
-
140
- test('browser fallback is skipped when getBrowser succeeds', () => {
141
- mockGetBrowser.mockReturnValue({ name: 'Chrome', version: '120.0' });
142
-
143
- const obj: componentInterface = {
144
- system: {
145
- browser: {
146
- name: 'Firefox',
147
- version: '120.0',
148
- },
149
- },
150
- };
151
-
152
- const result = getExcludeList({ ...defaultOptions, stabilize: ['private'] }, obj);
153
- // Should use Chrome rules (from getBrowser), not Firefox (from obj)
154
- expect(result).toContain('header.acceptLanguage'); // Chrome rule
155
- expect(result).not.toContain('fonts'); // Firefox-only rule
156
- });
157
-
158
- test('browser fallback handles missing system gracefully', () => {
159
- mockGetBrowser.mockReturnValue({ name: 'unknown', version: 'unknown' });
160
- const obj: componentInterface = { one: '1' };
161
- // Should not throw
162
- expect(() => getExcludeList({ ...defaultOptions }, obj)).not.toThrow();
163
- });
164
- });
165
-
166
- // ── filterThumbmarkData ─────────────────────────────────────────
167
-
168
- describe('filterThumbmarkData', () => {
169
- beforeEach(() => {
170
- mockGetBrowser.mockReturnValue({ name: 'unknown', version: 'unknown' });
171
- });
172
-
173
- test('returns full object with no options', () => {
174
- const result = filterThumbmarkData(testData);
175
- expect(result).toEqual(testData);
176
- });
177
-
178
- test('returns full object with empty exclude', () => {
179
- const result = filterThumbmarkData(testData, { ...defaultOptions, exclude: [], stabilize: [] });
180
- expect(result).toEqual(testData);
181
- });
182
-
183
- test('include overrides exclude for the same key', () => {
184
- const result = filterThumbmarkData(testData, {
185
- ...defaultOptions,
186
- exclude: ['one'],
187
- include: ['one'],
188
- stabilize: [],
189
- });
190
- expect(result.one).toBe('1');
191
- });
192
-
193
- test('include overrides exclude at nested level', () => {
194
- const result = filterThumbmarkData(testData, {
195
- ...defaultOptions,
196
- exclude: ['three'],
197
- include: ['three.a'],
198
- stabilize: [],
199
- });
200
- expect(result.three).toEqual({ a: true });
201
- expect((result.three as componentInterface).b).toBeUndefined();
202
- });
203
-
204
- test('excluding a parent removes all children', () => {
205
- const result = filterThumbmarkData(testData, {
206
- ...defaultOptions,
207
- exclude: ['three'],
208
- stabilize: [],
209
- });
210
- expect(result.three).toBeUndefined();
211
- });
212
-
213
- test('stabilization rules integrate with filterThumbmarkData', () => {
214
- mockGetBrowser.mockReturnValue({ name: 'Brave', version: '130.0' });
215
- const result = filterThumbmarkData(testData, {
216
- ...defaultOptions,
217
- stabilize: ['private'],
218
- });
219
- // Brave+private excludes canvas
220
- expect(result.canvas).toBeUndefined();
221
- // Brave+'always' excludes speech
222
- expect(result.speech).toBeUndefined();
223
- // Others remain
224
- expect(result.one).toBe('1');
225
- });
226
-
227
- test('nested exclusion via stabilization rules', () => {
228
- mockGetBrowser.mockReturnValue({ name: 'Brave', version: '130.0' });
229
- const result = filterThumbmarkData(testData, {
230
- ...defaultOptions,
231
- stabilize: ['private'],
232
- });
233
- // audio.sampleHash excluded for Brave, but audio.other should remain
234
- expect(result.audio).toBeDefined();
235
- expect((result.audio as componentInterface).other).toBe('jkl');
236
- expect((result.audio as componentInterface).sampleHash).toBeUndefined();
237
- });
238
- });