@xiboplayer/xmds 0.6.13 → 0.7.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/package.json +2 -2
- package/src/rest-client.js +33 -13
- package/src/rest-client.test.js +307 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xiboplayer/xmds",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
4
4
|
"description": "XMDS SOAP client for Xibo CMS communication",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/index.js",
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
"./schedule-parser": "./src/schedule-parser.js"
|
|
13
13
|
},
|
|
14
14
|
"dependencies": {
|
|
15
|
-
"@xiboplayer/utils": "0.
|
|
15
|
+
"@xiboplayer/utils": "0.7.0"
|
|
16
16
|
},
|
|
17
17
|
"devDependencies": {
|
|
18
18
|
"vitest": "^2.0.0"
|
package/src/rest-client.js
CHANGED
|
@@ -29,9 +29,10 @@ export class RestClient {
|
|
|
29
29
|
this._tokenExpiresAt = 0;
|
|
30
30
|
this._displayId = null;
|
|
31
31
|
|
|
32
|
-
// ETag-based HTTP caching
|
|
32
|
+
// ETag-based HTTP caching (capped at _maxCacheSize entries)
|
|
33
33
|
this._etags = new Map();
|
|
34
34
|
this._responseCache = new Map();
|
|
35
|
+
this._maxCacheSize = 100;
|
|
35
36
|
|
|
36
37
|
log.info('Using REST transport');
|
|
37
38
|
}
|
|
@@ -61,6 +62,19 @@ export class RestClient {
|
|
|
61
62
|
window.location.hostname === 'localhost');
|
|
62
63
|
}
|
|
63
64
|
|
|
65
|
+
/**
|
|
66
|
+
* Store a response in the ETag cache, evicting the oldest entry if full.
|
|
67
|
+
*/
|
|
68
|
+
_cacheSet(path, etag, data) {
|
|
69
|
+
if (this._etags.size >= this._maxCacheSize) {
|
|
70
|
+
const oldest = this._etags.keys().next().value;
|
|
71
|
+
this._etags.delete(oldest);
|
|
72
|
+
this._responseCache.delete(oldest);
|
|
73
|
+
}
|
|
74
|
+
this._etags.set(path, etag);
|
|
75
|
+
this._responseCache.set(path, data);
|
|
76
|
+
}
|
|
77
|
+
|
|
64
78
|
// ─── JWT auth ─────────────────────────────────────────────────
|
|
65
79
|
|
|
66
80
|
/**
|
|
@@ -108,7 +122,7 @@ export class RestClient {
|
|
|
108
122
|
/**
|
|
109
123
|
* Make an authenticated GET request with ETag caching.
|
|
110
124
|
*/
|
|
111
|
-
async restGet(path, queryParams = {}) {
|
|
125
|
+
async restGet(path, queryParams = {}, _retrying = false) {
|
|
112
126
|
const token = await this._getToken();
|
|
113
127
|
const url = new URL(`${this.getRestBaseUrl()}${path}`);
|
|
114
128
|
for (const [key, value] of Object.entries(queryParams)) {
|
|
@@ -131,8 +145,11 @@ export class RestClient {
|
|
|
131
145
|
|
|
132
146
|
// Token expired mid-flight — re-auth and retry once
|
|
133
147
|
if (response.status === 401) {
|
|
148
|
+
if (_retrying) {
|
|
149
|
+
throw new Error(`REST GET ${path} failed: 401 Unauthorized (after re-auth)`);
|
|
150
|
+
}
|
|
134
151
|
this._token = null;
|
|
135
|
-
return this.restGet(path, queryParams);
|
|
152
|
+
return this.restGet(path, queryParams, true);
|
|
136
153
|
}
|
|
137
154
|
|
|
138
155
|
if (response.status === 304) {
|
|
@@ -148,11 +165,6 @@ export class RestClient {
|
|
|
148
165
|
throw new Error(`REST GET ${path} failed: ${response.status} ${response.statusText} ${errorBody}`);
|
|
149
166
|
}
|
|
150
167
|
|
|
151
|
-
const etag = response.headers.get('ETag');
|
|
152
|
-
if (etag) {
|
|
153
|
-
this._etags.set(cacheKey, etag);
|
|
154
|
-
}
|
|
155
|
-
|
|
156
168
|
const contentType = response.headers.get('Content-Type') || '';
|
|
157
169
|
let data;
|
|
158
170
|
if (contentType.includes('application/json')) {
|
|
@@ -161,14 +173,18 @@ export class RestClient {
|
|
|
161
173
|
data = await response.text();
|
|
162
174
|
}
|
|
163
175
|
|
|
164
|
-
|
|
176
|
+
const etag = response.headers.get('ETag');
|
|
177
|
+
if (etag) {
|
|
178
|
+
this._cacheSet(cacheKey, etag, data);
|
|
179
|
+
}
|
|
180
|
+
|
|
165
181
|
return data;
|
|
166
182
|
}
|
|
167
183
|
|
|
168
184
|
/**
|
|
169
185
|
* Make an authenticated POST/PUT request with JSON body.
|
|
170
186
|
*/
|
|
171
|
-
async restSend(method, path, body = {}) {
|
|
187
|
+
async restSend(method, path, body = {}, _retrying = false) {
|
|
172
188
|
const token = await this._getToken();
|
|
173
189
|
const url = new URL(`${this.getRestBaseUrl()}${path}`);
|
|
174
190
|
|
|
@@ -185,8 +201,11 @@ export class RestClient {
|
|
|
185
201
|
|
|
186
202
|
// Token expired mid-flight — re-auth and retry once
|
|
187
203
|
if (response.status === 401) {
|
|
204
|
+
if (_retrying) {
|
|
205
|
+
throw new Error(`REST ${method} ${path} failed: 401 Unauthorized (after re-auth)`);
|
|
206
|
+
}
|
|
188
207
|
this._token = null;
|
|
189
|
-
return this.restSend(method, path, body);
|
|
208
|
+
return this.restSend(method, path, body, true);
|
|
190
209
|
}
|
|
191
210
|
|
|
192
211
|
if (!response.ok) {
|
|
@@ -283,13 +302,14 @@ export class RestClient {
|
|
|
283
302
|
version_instructions: attrs.version_instructions || display.version_instructions || null,
|
|
284
303
|
};
|
|
285
304
|
|
|
286
|
-
|
|
305
|
+
// Prefer structured syncConfig from REST module; fall back to flat SOAP fields
|
|
306
|
+
const syncConfig = display.syncConfig || (display.syncGroup ? {
|
|
287
307
|
syncGroup: String(display.syncGroup),
|
|
288
308
|
syncPublisherPort: parseInt(display.syncPublisherPort || '9590', 10),
|
|
289
309
|
syncSwitchDelay: parseInt(display.syncSwitchDelay || '750', 10),
|
|
290
310
|
syncVideoPauseDelay: parseInt(display.syncVideoPauseDelay || '100', 10),
|
|
291
311
|
isLead: String(display.syncGroup) === 'lead',
|
|
292
|
-
} : null;
|
|
312
|
+
} : null);
|
|
293
313
|
|
|
294
314
|
return { code, message, settings, tags, commands, displayAttrs, checkRf, checkSchedule, syncConfig };
|
|
295
315
|
}
|
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
// Copyright (c) 2024-2026 Pau Aliagas <linuxnow@gmail.com>
|
|
3
|
+
// @vitest-environment jsdom
|
|
4
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
5
|
+
|
|
6
|
+
// Mock @xiboplayer/utils before importing RestClient
|
|
7
|
+
vi.mock('@xiboplayer/utils', () => ({
|
|
8
|
+
createLogger: () => ({
|
|
9
|
+
info: vi.fn(),
|
|
10
|
+
debug: vi.fn(),
|
|
11
|
+
warn: vi.fn(),
|
|
12
|
+
error: vi.fn(),
|
|
13
|
+
}),
|
|
14
|
+
fetchWithRetry: vi.fn(),
|
|
15
|
+
PLAYER_API: '/player/api/v2',
|
|
16
|
+
}));
|
|
17
|
+
|
|
18
|
+
import { RestClient } from './rest-client.js';
|
|
19
|
+
import { fetchWithRetry } from '@xiboplayer/utils';
|
|
20
|
+
|
|
21
|
+
const mockConfig = {
|
|
22
|
+
cmsUrl: 'https://cms.example.com',
|
|
23
|
+
cmsKey: 'serverkey123',
|
|
24
|
+
hardwareKey: 'hw-001',
|
|
25
|
+
displayName: 'Test Display',
|
|
26
|
+
xmrChannel: 'xmr-ch',
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
/** Helper: create a mock Response object */
|
|
30
|
+
function mockResponse(status, body, headers = {}) {
|
|
31
|
+
return {
|
|
32
|
+
ok: status >= 200 && status < 300,
|
|
33
|
+
status,
|
|
34
|
+
statusText: status === 200 ? 'OK' : status === 304 ? 'Not Modified' : status === 401 ? 'Unauthorized' : 'Error',
|
|
35
|
+
headers: {
|
|
36
|
+
get: (name) => headers[name] || null,
|
|
37
|
+
},
|
|
38
|
+
json: vi.fn().mockResolvedValue(body),
|
|
39
|
+
text: vi.fn().mockResolvedValue(typeof body === 'string' ? body : JSON.stringify(body)),
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Helper: mock a successful auth response */
|
|
44
|
+
function mockAuthResponse() {
|
|
45
|
+
return mockResponse(200, {
|
|
46
|
+
token: 'jwt-token-123',
|
|
47
|
+
displayId: 42,
|
|
48
|
+
expiresIn: 3600,
|
|
49
|
+
}, { 'Content-Type': 'application/json' });
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
describe('RestClient', () => {
|
|
53
|
+
let client;
|
|
54
|
+
|
|
55
|
+
beforeEach(() => {
|
|
56
|
+
vi.clearAllMocks();
|
|
57
|
+
client = new RestClient(mockConfig);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
afterEach(() => {
|
|
61
|
+
vi.restoreAllMocks();
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
// ─── JWT authentication ─────────────────────────────────────
|
|
65
|
+
|
|
66
|
+
describe('JWT authentication', () => {
|
|
67
|
+
it('authenticates and stores token on first request', async () => {
|
|
68
|
+
const authResp = mockAuthResponse();
|
|
69
|
+
const dataResp = mockResponse(200, { ok: true }, { 'Content-Type': 'application/json' });
|
|
70
|
+
|
|
71
|
+
fetchWithRetry
|
|
72
|
+
.mockResolvedValueOnce(authResp) // POST /auth
|
|
73
|
+
.mockResolvedValueOnce(dataResp); // GET /schedule
|
|
74
|
+
|
|
75
|
+
const result = await client.restGet('/displays/42/schedule');
|
|
76
|
+
|
|
77
|
+
expect(result).toEqual({ ok: true });
|
|
78
|
+
expect(fetchWithRetry).toHaveBeenCalledTimes(2);
|
|
79
|
+
// First call is auth
|
|
80
|
+
expect(fetchWithRetry.mock.calls[0][1].method).toBe('POST');
|
|
81
|
+
expect(client._token).toBe('jwt-token-123');
|
|
82
|
+
expect(client._displayId).toBe(42);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('reuses token on subsequent requests', async () => {
|
|
86
|
+
const authResp = mockAuthResponse();
|
|
87
|
+
const resp1 = mockResponse(200, { a: 1 }, { 'Content-Type': 'application/json' });
|
|
88
|
+
const resp2 = mockResponse(200, { b: 2 }, { 'Content-Type': 'application/json' });
|
|
89
|
+
|
|
90
|
+
fetchWithRetry
|
|
91
|
+
.mockResolvedValueOnce(authResp)
|
|
92
|
+
.mockResolvedValueOnce(resp1)
|
|
93
|
+
.mockResolvedValueOnce(resp2);
|
|
94
|
+
|
|
95
|
+
await client.restGet('/foo');
|
|
96
|
+
await client.restGet('/bar');
|
|
97
|
+
|
|
98
|
+
// Only one auth call, two data calls
|
|
99
|
+
expect(fetchWithRetry).toHaveBeenCalledTimes(3);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('throws on auth failure', async () => {
|
|
103
|
+
fetchWithRetry.mockResolvedValueOnce(mockResponse(403, 'Forbidden'));
|
|
104
|
+
|
|
105
|
+
await expect(client.restGet('/anything')).rejects.toThrow('Auth failed: 403');
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
// ─── ETag caching ───────────────────────────────────────────
|
|
110
|
+
|
|
111
|
+
describe('ETag caching', () => {
|
|
112
|
+
it('returns cached response on 304', async () => {
|
|
113
|
+
const authResp = mockAuthResponse();
|
|
114
|
+
const firstResp = mockResponse(200, { data: 'fresh' }, {
|
|
115
|
+
'Content-Type': 'application/json',
|
|
116
|
+
'ETag': '"etag-1"',
|
|
117
|
+
});
|
|
118
|
+
const cachedResp = mockResponse(304, null);
|
|
119
|
+
|
|
120
|
+
fetchWithRetry
|
|
121
|
+
.mockResolvedValueOnce(authResp)
|
|
122
|
+
.mockResolvedValueOnce(firstResp)
|
|
123
|
+
.mockResolvedValueOnce(cachedResp);
|
|
124
|
+
|
|
125
|
+
// First call populates cache
|
|
126
|
+
const first = await client.restGet('/resource');
|
|
127
|
+
expect(first).toEqual({ data: 'fresh' });
|
|
128
|
+
|
|
129
|
+
// Second call gets 304, returns cached
|
|
130
|
+
const second = await client.restGet('/resource');
|
|
131
|
+
expect(second).toEqual({ data: 'fresh' });
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it('sends If-None-Match header when ETag is cached', async () => {
|
|
135
|
+
const authResp = mockAuthResponse();
|
|
136
|
+
const firstResp = mockResponse(200, { data: 1 }, {
|
|
137
|
+
'Content-Type': 'application/json',
|
|
138
|
+
'ETag': '"my-etag"',
|
|
139
|
+
});
|
|
140
|
+
const secondResp = mockResponse(304, null);
|
|
141
|
+
|
|
142
|
+
fetchWithRetry
|
|
143
|
+
.mockResolvedValueOnce(authResp)
|
|
144
|
+
.mockResolvedValueOnce(firstResp)
|
|
145
|
+
.mockResolvedValueOnce(secondResp);
|
|
146
|
+
|
|
147
|
+
await client.restGet('/path');
|
|
148
|
+
await client.restGet('/path');
|
|
149
|
+
|
|
150
|
+
// Third fetchWithRetry call (second GET) should include If-None-Match
|
|
151
|
+
const secondGetCall = fetchWithRetry.mock.calls[2];
|
|
152
|
+
expect(secondGetCall[1].headers['If-None-Match']).toBe('"my-etag"');
|
|
153
|
+
});
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
// ─── 401 retry guard ───────────────────────────────────────
|
|
157
|
+
|
|
158
|
+
describe('401 retry guard', () => {
|
|
159
|
+
it('restGet retries once on 401 then succeeds', async () => {
|
|
160
|
+
const authResp1 = mockAuthResponse();
|
|
161
|
+
const unauthorizedResp = mockResponse(401, 'Unauthorized');
|
|
162
|
+
const authResp2 = mockAuthResponse();
|
|
163
|
+
const successResp = mockResponse(200, { ok: true }, { 'Content-Type': 'application/json' });
|
|
164
|
+
|
|
165
|
+
fetchWithRetry
|
|
166
|
+
.mockResolvedValueOnce(authResp1) // first auth
|
|
167
|
+
.mockResolvedValueOnce(unauthorizedResp) // GET returns 401
|
|
168
|
+
.mockResolvedValueOnce(authResp2) // re-auth
|
|
169
|
+
.mockResolvedValueOnce(successResp); // retry GET succeeds
|
|
170
|
+
|
|
171
|
+
const result = await client.restGet('/resource');
|
|
172
|
+
expect(result).toEqual({ ok: true });
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it('restGet throws on second consecutive 401 (no infinite recursion)', async () => {
|
|
176
|
+
const authResp1 = mockAuthResponse();
|
|
177
|
+
const unauth1 = mockResponse(401, 'Unauthorized');
|
|
178
|
+
const authResp2 = mockAuthResponse();
|
|
179
|
+
const unauth2 = mockResponse(401, 'Unauthorized');
|
|
180
|
+
|
|
181
|
+
fetchWithRetry
|
|
182
|
+
.mockResolvedValueOnce(authResp1)
|
|
183
|
+
.mockResolvedValueOnce(unauth1)
|
|
184
|
+
.mockResolvedValueOnce(authResp2)
|
|
185
|
+
.mockResolvedValueOnce(unauth2);
|
|
186
|
+
|
|
187
|
+
await expect(client.restGet('/resource'))
|
|
188
|
+
.rejects.toThrow('401 Unauthorized (after re-auth)');
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it('restSend retries once on 401 then succeeds', async () => {
|
|
192
|
+
const authResp1 = mockAuthResponse();
|
|
193
|
+
const unauth = mockResponse(401, 'Unauthorized');
|
|
194
|
+
const authResp2 = mockAuthResponse();
|
|
195
|
+
const success = mockResponse(200, { saved: true }, { 'Content-Type': 'application/json' });
|
|
196
|
+
|
|
197
|
+
fetchWithRetry
|
|
198
|
+
.mockResolvedValueOnce(authResp1)
|
|
199
|
+
.mockResolvedValueOnce(unauth)
|
|
200
|
+
.mockResolvedValueOnce(authResp2)
|
|
201
|
+
.mockResolvedValueOnce(success);
|
|
202
|
+
|
|
203
|
+
const result = await client.restSend('PUT', '/resource', { key: 'value' });
|
|
204
|
+
expect(result).toEqual({ saved: true });
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it('restSend throws on second consecutive 401 (no infinite recursion)', async () => {
|
|
208
|
+
const authResp1 = mockAuthResponse();
|
|
209
|
+
const unauth1 = mockResponse(401, 'Unauthorized');
|
|
210
|
+
const authResp2 = mockAuthResponse();
|
|
211
|
+
const unauth2 = mockResponse(401, 'Unauthorized');
|
|
212
|
+
|
|
213
|
+
fetchWithRetry
|
|
214
|
+
.mockResolvedValueOnce(authResp1)
|
|
215
|
+
.mockResolvedValueOnce(unauth1)
|
|
216
|
+
.mockResolvedValueOnce(authResp2)
|
|
217
|
+
.mockResolvedValueOnce(unauth2);
|
|
218
|
+
|
|
219
|
+
await expect(client.restSend('POST', '/resource', {}))
|
|
220
|
+
.rejects.toThrow('401 Unauthorized (after re-auth)');
|
|
221
|
+
});
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
// ─── Cache eviction ─────────────────────────────────────────
|
|
225
|
+
|
|
226
|
+
describe('cache eviction', () => {
|
|
227
|
+
it('evicts oldest entry when cache exceeds max size', () => {
|
|
228
|
+
// Fill cache to max
|
|
229
|
+
for (let i = 0; i < 100; i++) {
|
|
230
|
+
client._cacheSet(`/path-${i}`, `etag-${i}`, { i });
|
|
231
|
+
}
|
|
232
|
+
expect(client._etags.size).toBe(100);
|
|
233
|
+
expect(client._responseCache.size).toBe(100);
|
|
234
|
+
|
|
235
|
+
// Adding one more should evict /path-0
|
|
236
|
+
client._cacheSet('/path-new', 'etag-new', { new: true });
|
|
237
|
+
expect(client._etags.size).toBe(100);
|
|
238
|
+
expect(client._etags.has('/path-0')).toBe(false);
|
|
239
|
+
expect(client._responseCache.has('/path-0')).toBe(false);
|
|
240
|
+
expect(client._etags.has('/path-new')).toBe(true);
|
|
241
|
+
expect(client._responseCache.get('/path-new')).toEqual({ new: true });
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
it('does not evict when under max size', () => {
|
|
245
|
+
client._cacheSet('/a', 'etag-a', 'data-a');
|
|
246
|
+
client._cacheSet('/b', 'etag-b', 'data-b');
|
|
247
|
+
expect(client._etags.size).toBe(2);
|
|
248
|
+
expect(client._etags.has('/a')).toBe(true);
|
|
249
|
+
expect(client._etags.has('/b')).toBe(true);
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
it('evicts multiple oldest entries on sequential inserts', () => {
|
|
253
|
+
for (let i = 0; i < 100; i++) {
|
|
254
|
+
client._cacheSet(`/p-${i}`, `e-${i}`, i);
|
|
255
|
+
}
|
|
256
|
+
// Add 3 more
|
|
257
|
+
client._cacheSet('/x1', 'ex1', 'x1');
|
|
258
|
+
client._cacheSet('/x2', 'ex2', 'x2');
|
|
259
|
+
client._cacheSet('/x3', 'ex3', 'x3');
|
|
260
|
+
|
|
261
|
+
expect(client._etags.size).toBe(100);
|
|
262
|
+
expect(client._etags.has('/p-0')).toBe(false);
|
|
263
|
+
expect(client._etags.has('/p-1')).toBe(false);
|
|
264
|
+
expect(client._etags.has('/p-2')).toBe(false);
|
|
265
|
+
expect(client._etags.has('/p-3')).toBe(true); // still present
|
|
266
|
+
expect(client._etags.has('/x3')).toBe(true);
|
|
267
|
+
});
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
// ─── Proxy mode detection ──────────────────────────────────
|
|
271
|
+
|
|
272
|
+
describe('proxy mode detection', () => {
|
|
273
|
+
it('detects proxy mode when electronAPI.isElectron is set', () => {
|
|
274
|
+
window.electronAPI = { isElectron: true };
|
|
275
|
+
expect(client._isProxyMode()).toBe(true);
|
|
276
|
+
delete window.electronAPI;
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
it('detects proxy mode when hostname is localhost', () => {
|
|
280
|
+
// jsdom defaults to localhost
|
|
281
|
+
const original = window.location.hostname;
|
|
282
|
+
expect(client._isProxyMode()).toBe(original === 'localhost');
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
it('returns direct URL in non-proxy mode', () => {
|
|
286
|
+
// Ensure no proxy indicators
|
|
287
|
+
delete window.electronAPI;
|
|
288
|
+
// jsdom hostname is localhost, so override _isProxyMode
|
|
289
|
+
vi.spyOn(client, '_isProxyMode').mockReturnValue(false);
|
|
290
|
+
|
|
291
|
+
const url = client.getRestBaseUrl();
|
|
292
|
+
expect(url).toBe('https://cms.example.com/player/api/v2');
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
it('returns local origin URL in proxy mode via electronAPI', () => {
|
|
296
|
+
window.electronAPI = { isElectron: true };
|
|
297
|
+
|
|
298
|
+
const url = client.getRestBaseUrl();
|
|
299
|
+
expect(url).toContain('/player/api/v2');
|
|
300
|
+
// In proxy mode, uses window.location.origin (which is cms.example.com in vitest)
|
|
301
|
+
// The key assertion is that _isProxyMode is true and the URL uses origin
|
|
302
|
+
expect(url).toBe(`${window.location.origin}/player/api/v2`);
|
|
303
|
+
|
|
304
|
+
delete window.electronAPI;
|
|
305
|
+
});
|
|
306
|
+
});
|
|
307
|
+
});
|