@thumbmarkjs/thumbmarkjs 1.7.5 → 1.8.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,120 @@
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
+ });
@@ -0,0 +1,26 @@
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
+ }
package/src/factory.ts CHANGED
@@ -21,6 +21,8 @@ import getSystem from "./components/system";
21
21
  import getWebGL from "./components/webgl";
22
22
 
23
23
  // Import experimental component functions
24
+ import getIntl from "./components/intl";
25
+ import getMediaDevices from "./components/mediaDevices";
24
26
  import getWebRTC from "./components/webrtc";
25
27
  import getMathML from "./components/mathml";
26
28
  import getSpeech from "./components/speech";
@@ -48,7 +50,9 @@ export const tm_component_promises = {
48
50
  * @description key->function map of experimental components. Only resolved during logging.
49
51
  */
50
52
  export const tm_experimental_component_promises = {
53
+ 'intl': getIntl,
51
54
  'mathml': getMathML,
55
+ 'mediadevices': getMediaDevices,
52
56
  };
53
57
 
54
58
  // the component interface is the form of the JSON object the function's promise must return
@@ -275,6 +275,57 @@ describe('getApiPromise timeout behavior', () => {
275
275
  expect(result?.visitorId).toBe(storedVisitorId);
276
276
  });
277
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
+
278
329
  test('returns full cached response (not just visitorId) when cache exists on timeout', async () => {
279
330
  const noCacheOptions = {
280
331
  ...testOptions,
@@ -46,9 +46,62 @@ export interface apiResponse {
46
46
 
47
47
  // ===================== API Call Logic =====================
48
48
 
49
+ export class ApiError extends Error {
50
+ constructor(public status: number) {
51
+ super(`HTTP error! status: ${status}`);
52
+ }
53
+ }
54
+
49
55
  let currentApiPromise: Promise<apiResponse> | null = null;
50
56
  let apiPromiseResult: apiResponse | null = null;
51
57
 
58
+ const MAX_RETRIES = 3;
59
+ const RETRY_BACKOFF_MS = 200;
60
+
61
+ /**
62
+ * Calls the API endpoint once. Returns the response data on success.
63
+ * Throws ApiError on HTTP errors, or a native error on network failures.
64
+ */
65
+ async function callApi(
66
+ endpoint: string, body: any, options: OptionsAfterDefaults, visitorId: string | null,
67
+ ): Promise<apiResponse> {
68
+ const response = await fetch(endpoint, {
69
+ method: 'POST',
70
+ headers: {
71
+ 'x-api-key': options.api_key!,
72
+ 'Authorization': 'custom-authorized',
73
+ 'Content-Type': 'application/json',
74
+ },
75
+ body: JSON.stringify(body),
76
+ });
77
+
78
+ if (!response.ok) throw new ApiError(response.status);
79
+
80
+ const data = await response.json();
81
+ if (data.visitorId && data.visitorId !== visitorId) setVisitorId(data.visitorId, options);
82
+ apiPromiseResult = data;
83
+ setCachedApiResponse(options, data);
84
+ return data;
85
+ }
86
+
87
+ /**
88
+ * Calls callApi with retries on network errors.
89
+ * HTTP errors (ApiError) are not retried — only network failures.
90
+ */
91
+ async function callApiWithRetry(
92
+ endpoint: string, body: any, options: OptionsAfterDefaults, visitorId: string | null,
93
+ ): Promise<apiResponse> {
94
+ for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
95
+ if (attempt > 0) await new Promise(r => setTimeout(r, attempt * RETRY_BACKOFF_MS));
96
+ try {
97
+ return await callApi(endpoint, body, options, visitorId);
98
+ } catch (error) {
99
+ if (error instanceof ApiError || attempt === MAX_RETRIES - 1) throw error;
100
+ }
101
+ }
102
+ throw new Error('Unreachable');
103
+ }
104
+
52
105
  /**
53
106
  * Calls the Thumbmark API with the given components, using caching and deduplication.
54
107
  * Returns a promise for the API response or null on error.
@@ -109,61 +162,19 @@ export const getApiPromise = (
109
162
  }
110
163
  }
111
164
 
112
- const fetchPromise = fetch(endpoint, {
113
- method: 'POST',
114
- headers: {
115
- 'x-api-key': options.api_key!,
116
- 'Authorization': 'custom-authorized',
117
- 'Content-Type': 'application/json',
118
- },
119
- body: JSON.stringify(requestBody),
120
- })
121
- .then(response => {
122
- // Handle HTTP errors that aren't network errors
123
- if (!response.ok) {
124
- if (response.status === 403) {
125
- throw new Error('INVALID_API_KEY');
126
- }
127
- throw new Error(`HTTP error! status: ${response.status}`);
128
- }
129
- return response.json();
130
- })
131
- .then(data => {
132
- // Handle visitor ID from server response
133
- if (data.visitorId && data.visitorId !== visitorId) {
134
- setVisitorId(data.visitorId, options);
135
- }
136
- apiPromiseResult = data; // Cache the successful result
137
- setCachedApiResponse(options, data); // Cache to localStorage according to options
138
- currentApiPromise = null; // Clear the in-flight promise
139
- return data;
140
- })
141
- .catch(error => {
142
- currentApiPromise = null; // Clear the in-flight promise on error
143
- throw error; // Propagate all errors to caller
144
- });
145
-
146
- // Timeout logic
147
165
  const timeoutMs = options.timeout || 5000;
148
- const timeoutPromise = new Promise<apiResponse>((resolve) => {
166
+
167
+ const apiCall = callApiWithRetry(endpoint, requestBody, options, visitorId)
168
+ .finally(() => { currentApiPromise = null; });
169
+
170
+ const timeout = new Promise<apiResponse>((resolve) => {
149
171
  setTimeout(() => {
150
- // On timeout, try to return expired cache as fallback
151
- // Note: getCache() returns cache regardless of expiry
152
172
  const cache = getCache(options);
153
- if (cache && cache.apiResponse) {
154
- resolve(cache.apiResponse);
155
- } else {
156
- // Even without caching, return the visitor ID if available
157
- // (visitorId was already retrieved at the start of this function)
158
- resolve({
159
- info: { timed_out: true },
160
- ...(visitorId && { visitorId }),
161
- });
162
- }
173
+ resolve(cache?.apiResponse || { info: { timed_out: true }, ...(visitorId && { visitorId }) });
163
174
  }, timeoutMs);
164
175
  });
165
176
 
166
- currentApiPromise = Promise.race([fetchPromise, timeoutPromise]);
177
+ currentApiPromise = Promise.race([apiCall, timeout]);
167
178
  return currentApiPromise;
168
179
  };
169
180
 
@@ -21,7 +21,7 @@ import { raceAllPerformance } from "../utils/raceAll";
21
21
  import { getVersion } from "../utils/version";
22
22
  import { filterThumbmarkData, getExcludeList } from './filterComponents'
23
23
  import { logThumbmarkData } from '../utils/log';
24
- import { getApiPromise, infoInterface } from "./api";
24
+ import { getApiPromise, ApiError, infoInterface } from "./api";
25
25
  import { stableStringify } from "../utils/stableStringify";
26
26
 
27
27
 
@@ -29,7 +29,7 @@ import { stableStringify } from "../utils/stableStringify";
29
29
  * Final thumbmark response structure
30
30
  */
31
31
  export interface ThumbmarkError {
32
- type: 'component_timeout' | 'component_error' | 'api_timeout' | 'api_error' | 'api_unauthorized' | 'fatal';
32
+ type: 'component_timeout' | 'component_error' | 'api_timeout' | 'api_error' | 'api_unauthorized' | 'network_error' | 'fatal';
33
33
  message: string;
34
34
  component?: string;
35
35
  }
@@ -111,7 +111,7 @@ export async function getThumbmark(
111
111
  try {
112
112
  apiResult = await apiPromise;
113
113
  } catch (error) {
114
- if (error instanceof Error && error.message === 'INVALID_API_KEY') {
114
+ if (error instanceof ApiError && error.status === 403) {
115
115
  return {
116
116
  error: [{ type: 'api_unauthorized', message: 'Invalid API key or quota exceeded' }],
117
117
  components: {},
@@ -120,9 +120,8 @@ export async function getThumbmark(
120
120
  thumbmark: ''
121
121
  };
122
122
  }
123
- // Non-auth API errors (5xx, network): log error and continue without API data
124
123
  allErrors.push({
125
- type: 'api_error',
124
+ type: error instanceof ApiError ? 'api_error' : 'network_error',
126
125
  message: error instanceof Error ? error.message : String(error)
127
126
  });
128
127
  }