@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,124 +0,0 @@
1
- import getIntl from './index';
2
-
3
- describe('intl component tests', () => {
4
- const originalIntl = globalThis.Intl;
5
-
6
- afterEach(() => {
7
- Object.defineProperty(globalThis, 'Intl', {
8
- value: originalIntl,
9
- configurable: true,
10
- writable: true,
11
- });
12
- });
13
-
14
- test('returns valid structure with all expected keys', async () => {
15
- const result = await getIntl();
16
-
17
- expect(result).not.toBeNull();
18
- expect(result).toHaveProperty('hash');
19
- expect(result).toHaveProperty('details');
20
- expect(typeof result!.hash).toBe('string');
21
- const details = result!.details as Record<string, unknown>;
22
- const coreKeys = [
23
- 'dateFullFormat', 'dateMediumFormat', 'timeFormat',
24
- 'numberFormat', 'currencyFormat', 'percentFormat',
25
- 'nonLatinDate', 'nonLatinNumber',
26
- ];
27
- for (const key of coreKeys) {
28
- expect(details).toHaveProperty(key);
29
- expect(typeof details[key]).toBe('string');
30
- }
31
- });
32
-
33
- test('returns null when Intl is unavailable', async () => {
34
- Object.defineProperty(globalThis, 'Intl', {
35
- value: undefined,
36
- configurable: true,
37
- writable: true,
38
- });
39
-
40
- const result = await getIntl();
41
- expect(result).toBeNull();
42
- });
43
-
44
- test('omits listFormat when ListFormat is unavailable', async () => {
45
- const mockIntl = Object.create(originalIntl);
46
- Object.defineProperty(mockIntl, 'ListFormat', {
47
- value: undefined,
48
- configurable: true,
49
- });
50
- Object.defineProperty(globalThis, 'Intl', {
51
- value: mockIntl,
52
- configurable: true,
53
- writable: true,
54
- });
55
-
56
- const result = await getIntl();
57
- const details = result!.details as Record<string, unknown>;
58
-
59
- expect(result).not.toBeNull();
60
- expect(details).toHaveProperty('dateFullFormat');
61
- expect(details).not.toHaveProperty('listFormat');
62
- });
63
-
64
- test('omits displayNames when DisplayNames is unavailable', async () => {
65
- const mockIntl = Object.create(originalIntl);
66
- Object.defineProperty(mockIntl, 'DisplayNames', {
67
- value: undefined,
68
- configurable: true,
69
- });
70
- Object.defineProperty(globalThis, 'Intl', {
71
- value: mockIntl,
72
- configurable: true,
73
- writable: true,
74
- });
75
-
76
- const result = await getIntl();
77
- const details = result!.details as Record<string, unknown>;
78
-
79
- expect(result).not.toBeNull();
80
- expect(details).toHaveProperty('dateFullFormat');
81
- expect(details).not.toHaveProperty('displayNames');
82
- });
83
-
84
- test('includes guarded probes when available', async () => {
85
- const result = await getIntl();
86
- const details = result!.details as Record<string, unknown>;
87
-
88
- expect(result).not.toBeNull();
89
- if (typeof (Intl as any).ListFormat === 'function') {
90
- expect(details).toHaveProperty('listFormat');
91
- expect(typeof details.listFormat).toBe('string');
92
- }
93
- if (typeof (Intl as any).DisplayNames === 'function') {
94
- expect(details).toHaveProperty('displayNames');
95
- expect(typeof details.displayNames).toBe('string');
96
- }
97
- });
98
-
99
- test('returns null when Intl constructor throws', async () => {
100
- const mockIntl = Object.create(originalIntl);
101
- Object.defineProperty(mockIntl, 'DateTimeFormat', {
102
- value: function() { throw new Error('broken'); },
103
- configurable: true,
104
- });
105
- Object.defineProperty(globalThis, 'Intl', {
106
- value: mockIntl,
107
- configurable: true,
108
- writable: true,
109
- });
110
-
111
- const result = await getIntl();
112
- expect(result).toBeNull();
113
- });
114
-
115
- test('produces deterministic number format output', async () => {
116
- const result = await getIntl();
117
- const details = result!.details as Record<string, unknown>;
118
-
119
- expect(result).not.toBeNull();
120
- expect(details.numberFormat).toBe('123,456.789');
121
- expect(details.currencyFormat).toBe('$123,456.79');
122
- expect(details.percentFormat).toBe('46%');
123
- });
124
- });
@@ -1,41 +0,0 @@
1
- import { componentInterface } from '../../factory';
2
- import { hash } from '../../utils/hash';
3
- import { stableStringify } from '../../utils/stableStringify';
4
-
5
- export default async function getIntl(): Promise<componentInterface | null> {
6
- if (typeof Intl === 'undefined') {
7
- return null;
8
- }
9
-
10
- try {
11
- const date = new Date(Date.UTC(2024, 0, 15, 12, 30, 45));
12
-
13
- const details: componentInterface = {
14
- dateFullFormat: new Intl.DateTimeFormat('en-US', { dateStyle: 'full', timeZone: 'UTC' } as any).format(date),
15
- dateMediumFormat: new Intl.DateTimeFormat('en-US', { dateStyle: 'medium', timeZone: 'UTC' } as any).format(date),
16
- timeFormat: new Intl.DateTimeFormat('en-US', { timeStyle: 'long', timeZone: 'UTC' } as any).format(date),
17
- numberFormat: new Intl.NumberFormat('en-US').format(123456.789),
18
- currencyFormat: new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(123456.789),
19
- percentFormat: new Intl.NumberFormat('en-US', { style: 'percent' }).format(0.456),
20
- nonLatinDate: new Intl.DateTimeFormat('ar-EG', { timeZone: 'UTC' }).format(date),
21
- nonLatinNumber: new Intl.NumberFormat('zh-Hans-CN-u-nu-hanidec').format(123456.789),
22
- };
23
-
24
- const IntlAny = Intl as any;
25
-
26
- if (typeof IntlAny.ListFormat === 'function') {
27
- details.listFormat = new IntlAny.ListFormat('en', { type: 'conjunction' }).format(['a', 'b', 'c']);
28
- }
29
-
30
- if (typeof IntlAny.DisplayNames === 'function') {
31
- details.displayNames = new IntlAny.DisplayNames('en', { type: 'region' }).of('US') || '';
32
- }
33
-
34
- return {
35
- details,
36
- hash: hash(stableStringify(details)),
37
- };
38
- } catch {
39
- return null;
40
- }
41
- }
@@ -1,120 +0,0 @@
1
- import getMediaDevices from './index';
2
-
3
- describe('mediaDevices component tests', () => {
4
- let originalMediaDevices: MediaDevices | undefined;
5
-
6
- beforeAll(() => {
7
- originalMediaDevices = navigator.mediaDevices;
8
- });
9
-
10
- afterAll(() => {
11
- Object.defineProperty(navigator, 'mediaDevices', {
12
- value: originalMediaDevices,
13
- configurable: true,
14
- });
15
- });
16
-
17
- function mockEnumerateDevices(devices: Array<{ kind: string }>) {
18
- Object.defineProperty(navigator, 'mediaDevices', {
19
- value: {
20
- enumerateDevices: jest.fn().mockResolvedValue(devices),
21
- },
22
- configurable: true,
23
- });
24
- }
25
-
26
- test('returns valid structure when API is available', async () => {
27
- mockEnumerateDevices([
28
- { kind: 'audioinput' },
29
- { kind: 'audiooutput' },
30
- { kind: 'videoinput' },
31
- ]);
32
-
33
- const result = await getMediaDevices();
34
-
35
- expect(result).toEqual({
36
- audioinput: 1,
37
- audiooutput: 1,
38
- videoinput: 1,
39
- });
40
- });
41
-
42
- test('returns null when mediaDevices API is unavailable', async () => {
43
- Object.defineProperty(navigator, 'mediaDevices', {
44
- value: undefined,
45
- configurable: true,
46
- });
47
-
48
- const result = await getMediaDevices();
49
- expect(result).toBeNull();
50
- });
51
-
52
- test('returns null when enumerateDevices throws', async () => {
53
- Object.defineProperty(navigator, 'mediaDevices', {
54
- value: {
55
- enumerateDevices: jest.fn().mockRejectedValue(new Error('NotAllowedError')),
56
- },
57
- configurable: true,
58
- });
59
-
60
- const result = await getMediaDevices();
61
- expect(result).toBeNull();
62
- });
63
-
64
- test('handles empty device list', async () => {
65
- mockEnumerateDevices([]);
66
-
67
- const result = await getMediaDevices();
68
-
69
- expect(result).toEqual({
70
- audioinput: 0,
71
- audiooutput: 0,
72
- videoinput: 0,
73
- });
74
- });
75
-
76
- test('counts multiple devices of same kind', async () => {
77
- mockEnumerateDevices([
78
- { kind: 'videoinput' },
79
- { kind: 'videoinput' },
80
- { kind: 'audioinput' },
81
- ]);
82
-
83
- const result = await getMediaDevices();
84
-
85
- expect(result).toEqual({
86
- audioinput: 1,
87
- audiooutput: 0,
88
- videoinput: 2,
89
- });
90
- });
91
-
92
- test('ignores unknown device kind values', async () => {
93
- mockEnumerateDevices([
94
- { kind: 'audioinput' },
95
- { kind: '' },
96
- { kind: 'somethingelse' },
97
- { kind: 'videoinput' },
98
- ]);
99
-
100
- const result = await getMediaDevices();
101
-
102
- expect(result).toEqual({
103
- audioinput: 1,
104
- audiooutput: 0,
105
- videoinput: 1,
106
- });
107
- });
108
-
109
- test('returns null when enumerateDevices resolves with null', async () => {
110
- Object.defineProperty(navigator, 'mediaDevices', {
111
- value: {
112
- enumerateDevices: jest.fn().mockResolvedValue(null),
113
- },
114
- configurable: true,
115
- });
116
-
117
- const result = await getMediaDevices();
118
- expect(result).toBeNull();
119
- });
120
- });
@@ -1,26 +0,0 @@
1
- import { componentInterface } from '../../factory';
2
-
3
- export default async function getMediaDevices(): Promise<componentInterface | null> {
4
- if (typeof navigator === 'undefined' ||
5
- !navigator.mediaDevices ||
6
- typeof navigator.mediaDevices.enumerateDevices !== 'function') {
7
- return null;
8
- }
9
-
10
- try {
11
- const devices = await navigator.mediaDevices.enumerateDevices();
12
-
13
- const counts: Record<string, number> = {};
14
- for (const device of devices) {
15
- counts[device.kind] = (counts[device.kind] || 0) + 1;
16
- }
17
-
18
- return {
19
- audioinput: counts['audioinput'] || 0,
20
- audiooutput: counts['audiooutput'] || 0,
21
- videoinput: counts['videoinput'] || 0,
22
- };
23
- } catch {
24
- return null;
25
- }
26
- }
@@ -1,108 +0,0 @@
1
- import { getBrowser } from './browser';
2
-
3
- describe('getBrowser', () => {
4
- const originalNavigator = global.navigator;
5
-
6
- function mockUserAgent(ua: string) {
7
- Object.defineProperty(global, 'navigator', {
8
- value: { userAgent: ua },
9
- configurable: true,
10
- });
11
- }
12
-
13
- afterAll(() => {
14
- // Restore original navigator
15
- Object.defineProperty(global, 'navigator', {
16
- value: originalNavigator,
17
- configurable: true,
18
- });
19
- });
20
-
21
- // Authoritative user agent test cases from DeviceAtlas
22
- const cases = [
23
- // Chrome (Windows)
24
- {
25
- ua: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.6099.110 Safari/537.36',
26
- expected: { name: 'Chrome', version: '120.0.6099.110' },
27
- },
28
- // Firefox (Windows)
29
- {
30
- ua: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:120.0) Gecko/20100101 Firefox/120.0',
31
- expected: { name: 'Firefox', version: '120.0' },
32
- },
33
- // Edge (Windows)
34
- {
35
- ua: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.6099.110 Safari/537.36 Edg/120.0.2210.61',
36
- expected: { name: 'Edge', version: '120.0.2210.61' },
37
- },
38
- // Safari (macOS)
39
- {
40
- ua: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.1 Safari/605.1.15',
41
- expected: { name: 'Safari', version: '17.1' },
42
- },
43
- // Chrome (macOS)
44
- {
45
- ua: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.6099.110 Safari/537.36',
46
- expected: { name: 'Chrome', version: '120.0.6099.110' },
47
- },
48
- // Firefox (macOS)
49
- {
50
- ua: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:120.0) Gecko/20100101 Firefox/120.0',
51
- expected: { name: 'Firefox', version: '120.0' },
52
- },
53
- // Chrome (Android)
54
- {
55
- ua: 'Mozilla/5.0 (Linux; Android 13; SM-G991B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.6099.110 Mobile Safari/537.36',
56
- expected: { name: 'Chrome', version: '120.0.6099.110' },
57
- },
58
- // Samsung Internet (Android)
59
- {
60
- ua: 'Mozilla/5.0 (Linux; Android 13; SAMSUNG SM-G991B) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/23.0 Chrome/120.0.6099.110 Mobile Safari/537.36',
61
- expected: { name: 'SamsungBrowser', version: '23.0' },
62
- },
63
- // Firefox (Android)
64
- {
65
- ua: 'Mozilla/5.0 (Android 13; Mobile; rv:120.0) Gecko/120.0 Firefox/120.0',
66
- expected: { name: 'Firefox', version: '120.0' },
67
- },
68
- // Safari (iPhone)
69
- {
70
- ua: 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.1 Mobile/15E148 Safari/604.1',
71
- expected: { name: 'Safari', version: '17.1' },
72
- },
73
- // Chrome (iPhone)
74
- {
75
- ua: 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/120.0.6099.110 Mobile/15E148 Safari/604.1',
76
- expected: { name: 'Chrome', version: '120.0.6099.110' },
77
- },
78
- // Firefox (iPhone)
79
- {
80
- ua: 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) FxiOS/120.0 Mobile/15E148 Safari/604.1',
81
- expected: { name: 'Firefox', version: '120.0' },
82
- },
83
- // Opera (Windows)
84
- {
85
- ua: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.6099.110 Safari/537.36 OPR/107.0.5045.36',
86
- expected: { name: 'Opera', version: '107.0.5045.36' },
87
- },
88
- // Vivaldi (Windows)
89
- {
90
- ua: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.6099.110 Safari/537.36 Vivaldi/6.4.3160.49',
91
- expected: { name: 'Vivaldi', version: '6.4.3160.49' },
92
- },
93
- // Unknown
94
- {
95
- ua: 'SomeRandomUserAgent/1.0',
96
- expected: { name: 'SomeRandomUserAgent', version: '1.0' },
97
- },
98
- ];
99
-
100
- cases.forEach(({ ua, expected }) => {
101
- it(`should detect ${expected.name} ${expected.version} from UA: ${ua}`, () => {
102
- mockUserAgent(ua);
103
- const result = getBrowser();
104
- expect(result.name.toLowerCase()).toBe(expected.name.toLowerCase());
105
- expect(result.version).toContain(expected.version.split('.')[0]); // loose match for version
106
- });
107
- });
108
- });
@@ -1,223 +0,0 @@
1
- import getWebGL, { __resetWebGLCache, __getWebGLCache } from './index';
2
-
3
- // ---------------------------------------------------------------------------
4
- // Mock ImageData — jsdom does not provide a real implementation
5
- // ---------------------------------------------------------------------------
6
- class MockImageData {
7
- data: Uint8ClampedArray;
8
- width: number;
9
- height: number;
10
-
11
- constructor(dataOrWidth: Uint8ClampedArray | number, widthOrUndefined?: number, height?: number) {
12
- if (dataOrWidth instanceof Uint8ClampedArray) {
13
- this.data = dataOrWidth;
14
- this.width = widthOrUndefined ?? 1;
15
- this.height = height ?? 1;
16
- } else {
17
- const w = dataOrWidth as number;
18
- const h = widthOrUndefined ?? 1;
19
- this.data = new Uint8ClampedArray(w * h * 4);
20
- this.width = w;
21
- this.height = h;
22
- }
23
- }
24
- }
25
- (global as any).ImageData = MockImageData;
26
-
27
- // ---------------------------------------------------------------------------
28
- // WebGL mock factory — returns an object satisfying setupWebGL() and renderImage()
29
- // ---------------------------------------------------------------------------
30
- function makeGlMock(overrides: Partial<WebGLRenderingContext> = {}): WebGLRenderingContext {
31
- const gl: any = {
32
- VERTEX_SHADER: 35633,
33
- FRAGMENT_SHADER: 35632,
34
- ARRAY_BUFFER: 34962,
35
- COMPILE_STATUS: 35713,
36
- LINK_STATUS: 35714,
37
- RGBA: 6408,
38
- UNSIGNED_BYTE: 5121,
39
- COLOR_BUFFER_BIT: 16384,
40
- FLOAT: 5126,
41
- LINES: 1,
42
- drawingBufferWidth: 200,
43
- drawingBufferHeight: 100,
44
- createShader: jest.fn().mockReturnValue({}),
45
- shaderSource: jest.fn(),
46
- compileShader: jest.fn(),
47
- getShaderParameter: jest.fn().mockReturnValue(true),
48
- createProgram: jest.fn().mockReturnValue({}),
49
- attachShader: jest.fn(),
50
- linkProgram: jest.fn(),
51
- getProgramParameter: jest.fn().mockReturnValue(true),
52
- createBuffer: jest.fn().mockReturnValue({}),
53
- bindBuffer: jest.fn(),
54
- bufferData: jest.fn(),
55
- useProgram: jest.fn(),
56
- getAttribLocation: jest.fn().mockReturnValue(0),
57
- enableVertexAttribArray: jest.fn(),
58
- vertexAttribPointer: jest.fn(),
59
- viewport: jest.fn(),
60
- clearColor: jest.fn(),
61
- clear: jest.fn(),
62
- drawArrays: jest.fn(),
63
- readPixels: jest.fn(),
64
- ...overrides,
65
- };
66
- return gl as WebGLRenderingContext;
67
- }
68
-
69
- // ---------------------------------------------------------------------------
70
- // Suite setup
71
- // ---------------------------------------------------------------------------
72
- let originalGetContext: any;
73
-
74
- beforeAll(() => {
75
- originalGetContext = HTMLCanvasElement.prototype.getContext;
76
- });
77
-
78
- afterAll(() => {
79
- HTMLCanvasElement.prototype.getContext = originalGetContext;
80
- });
81
-
82
- beforeEach(() => {
83
- __resetWebGLCache();
84
- });
85
-
86
- // ---------------------------------------------------------------------------
87
- // Tests
88
- // ---------------------------------------------------------------------------
89
- describe('webgl component — context-loss recovery', () => {
90
-
91
- // Happy path: cache is populated after first successful call
92
- test('first getWebGL() call populates the cache', async () => {
93
- HTMLCanvasElement.prototype.getContext = jest.fn().mockReturnValue(makeGlMock()) as any;
94
-
95
- await getWebGL();
96
-
97
- expect(__getWebGLCache()).not.toBeNull();
98
- });
99
-
100
- // Cache reuse: two calls share the same canvas object
101
- test('two consecutive getWebGL() calls reuse the same cached canvas', async () => {
102
- HTMLCanvasElement.prototype.getContext = jest.fn().mockReturnValue(makeGlMock()) as any;
103
-
104
- await getWebGL();
105
- const cacheAfterFirst = __getWebGLCache();
106
- expect(cacheAfterFirst).not.toBeNull();
107
-
108
- await getWebGL();
109
- const cacheAfterSecond = __getWebGLCache();
110
-
111
- // Same object reference — no new canvas was created
112
- expect(cacheAfterSecond).toBe(cacheAfterFirst);
113
- // getContext called exactly once (canvas created once)
114
- expect(HTMLCanvasElement.prototype.getContext).toHaveBeenCalledTimes(1);
115
- });
116
-
117
- // Context-loss listener: dispatching webglcontextlost clears the cache
118
- test('webglcontextlost event clears the cache', async () => {
119
- HTMLCanvasElement.prototype.getContext = jest.fn().mockReturnValue(makeGlMock()) as any;
120
-
121
- await getWebGL();
122
- const cacheBeforeLoss = __getWebGLCache();
123
- expect(cacheBeforeLoss).not.toBeNull();
124
-
125
- // Fire the context-lost event on the cached canvas
126
- cacheBeforeLoss!.canvas.dispatchEvent(new Event('webglcontextlost', { cancelable: true }));
127
-
128
- expect(__getWebGLCache()).toBeNull();
129
- });
130
-
131
- // Context-loss listener: event.preventDefault() is called
132
- test('webglcontextlost listener calls event.preventDefault()', async () => {
133
- HTMLCanvasElement.prototype.getContext = jest.fn().mockReturnValue(makeGlMock()) as any;
134
-
135
- await getWebGL();
136
- const cache = __getWebGLCache();
137
- expect(cache).not.toBeNull();
138
-
139
- const lostEvent = new Event('webglcontextlost', { cancelable: true });
140
- const preventDefaultSpy = jest.spyOn(lostEvent, 'preventDefault');
141
-
142
- cache!.canvas.dispatchEvent(lostEvent);
143
-
144
- expect(preventDefaultSpy).toHaveBeenCalledTimes(1);
145
- });
146
-
147
- // After context loss, next getWebGL() call creates a NEW canvas
148
- test('getWebGL() after context loss rebuilds the cache via a new canvas', async () => {
149
- const gl = makeGlMock();
150
- HTMLCanvasElement.prototype.getContext = jest.fn().mockReturnValue(gl) as any;
151
-
152
- await getWebGL();
153
- const firstCache = __getWebGLCache();
154
- expect(firstCache).not.toBeNull();
155
-
156
- // Simulate context loss
157
- firstCache!.canvas.dispatchEvent(new Event('webglcontextlost', { cancelable: true }));
158
- expect(__getWebGLCache()).toBeNull();
159
-
160
- // Reset mock call count, re-install for rebuild
161
- HTMLCanvasElement.prototype.getContext = jest.fn().mockReturnValue(gl) as any;
162
-
163
- await getWebGL();
164
- const secondCache = __getWebGLCache();
165
- expect(secondCache).not.toBeNull();
166
- // A fresh cache object is created — different reference from the lost one
167
- expect(secondCache).not.toBe(firstCache);
168
- // getContext called once for the rebuild (new canvas)
169
- expect(HTMLCanvasElement.prototype.getContext).toHaveBeenCalledTimes(1);
170
- });
171
-
172
- // once: true — the listener fires at most once per canvas
173
- test('webglcontextlost listener fires only once (once:true)', async () => {
174
- HTMLCanvasElement.prototype.getContext = jest.fn().mockReturnValue(makeGlMock()) as any;
175
-
176
- await getWebGL();
177
- const firstCache = __getWebGLCache();
178
- const canvas = firstCache!.canvas;
179
-
180
- // First dispatch: clears cache
181
- canvas.dispatchEvent(new Event('webglcontextlost', { cancelable: true }));
182
- expect(__getWebGLCache()).toBeNull();
183
-
184
- // Second dispatch on the same canvas — listener already removed (once:true),
185
- // so _cache remains null (unchanged) and no throw occurs
186
- expect(() => {
187
- canvas.dispatchEvent(new Event('webglcontextlost', { cancelable: true }));
188
- }).not.toThrow();
189
- expect(__getWebGLCache()).toBeNull();
190
- });
191
-
192
- // renderImage catch block: render failure clears the cache
193
- test('render failure (useProgram throws) clears the cache', async () => {
194
- HTMLCanvasElement.prototype.getContext = jest.fn().mockReturnValue(makeGlMock()) as any;
195
-
196
- // First call populates cache
197
- await getWebGL();
198
- expect(__getWebGLCache()).not.toBeNull();
199
-
200
- // Patch the cached gl to throw on the next render
201
- const cache = __getWebGLCache()!;
202
- (cache as any).gl.useProgram = jest.fn().mockImplementation(() => {
203
- throw new Error('WebGL context lost');
204
- });
205
-
206
- // The call should not throw; it should return the 'unsupported' fallback
207
- const result = await getWebGL();
208
- expect(result).toEqual({ webgl: 'unsupported' });
209
-
210
- // Cache must have been cleared by the catch block
211
- expect(__getWebGLCache()).toBeNull();
212
- });
213
-
214
- // Graceful degradation: getContext returns null → returns 'unsupported'
215
- test('getWebGL() returns unsupported when WebGL context is unavailable', async () => {
216
- HTMLCanvasElement.prototype.getContext = jest.fn().mockReturnValue(null) as any;
217
-
218
- const result = await getWebGL();
219
-
220
- expect(result).toEqual({ webgl: 'unsupported' });
221
- expect(__getWebGLCache()).toBeNull();
222
- });
223
- });