@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.
- package/dist/thumbmark.cjs.js +1 -1
- package/dist/thumbmark.cjs.js.map +1 -1
- package/dist/thumbmark.esm.d.ts +1 -1
- package/dist/thumbmark.esm.js +1 -1
- package/dist/thumbmark.esm.js.map +1 -1
- package/dist/thumbmark.umd.js +1 -1
- package/dist/thumbmark.umd.js.map +1 -1
- package/package.json +1 -1
- package/src/components/intl/index.test.ts +124 -0
- package/src/components/intl/index.ts +41 -0
- package/src/components/mediaDevices/index.test.ts +120 -0
- package/src/components/mediaDevices/index.ts +26 -0
- package/src/factory.ts +4 -0
- package/src/functions/api.test.ts +51 -0
- package/src/functions/api.ts +60 -49
- package/src/functions/index.ts +4 -5
|
@@ -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,
|
package/src/functions/api.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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([
|
|
177
|
+
currentApiPromise = Promise.race([apiCall, timeout]);
|
|
167
178
|
return currentApiPromise;
|
|
168
179
|
};
|
|
169
180
|
|
package/src/functions/index.ts
CHANGED
|
@@ -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
|
|
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
|
}
|