@xiboplayer/xmds 0.7.17 → 0.7.19

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xiboplayer/xmds",
3
- "version": "0.7.17",
3
+ "version": "0.7.19",
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.7.17"
15
+ "@xiboplayer/utils": "0.7.19"
16
16
  },
17
17
  "devDependencies": {
18
18
  "vitest": "^4.1.2"
@@ -8,22 +8,30 @@
8
8
  * - SOAP/XMDS — universal XML protocol (any vanilla Xibo CMS)
9
9
  *
10
10
  * Detection logic:
11
- * 1. GET {cmsUrl}${PLAYER_API}/health with a 3-second timeout
11
+ * 1. GET {cmsUrl}${PLAYER_API}/health with a generous timeout
12
12
  * 2. If 200 + valid JSON → REST
13
13
  * 3. If 404/error/timeout → SOAP (fallback)
14
14
  *
15
- * The detected protocol is cached and can be re-probed on connection errors.
15
+ * After a fallback to XMDS, the detector can run an automatic background
16
+ * re-probe loop with exponential backoff so the player promotes back to
17
+ * REST as soon as the CMS recovers. The re-probe loop also acts as a
18
+ * visibility signal: each failed probe emits a warning that names the
19
+ * next attempt, so it's obvious at a glance that the player is still
20
+ * running on the wrong transport.
16
21
  *
17
22
  * @example
18
- * import { ProtocolDetector } from '@xiboplayer/xmds';
19
- * import { RestClient, XmdsClient } from '@xiboplayer/xmds';
23
+ * import { ProtocolDetector, RestClient, XmdsClient } from '@xiboplayer/xmds';
20
24
  *
21
25
  * const detector = new ProtocolDetector(config.cmsUrl, RestClient, XmdsClient);
22
- * const xmds = await detector.detect(config);
23
- * // xmds is either a RestClient or XmdsClient instance
26
+ * const { client, protocol } = await detector.detect(config);
27
+ * let xmds = client;
24
28
  *
25
- * // On connection errors, re-probe:
26
- * const newXmds = await detector.reprobe(config);
29
+ * if (protocol === 'xmds') {
30
+ * detector.startAutoReprobe(config, (newClient) => {
31
+ * // REST recovered — swap the live client pointer
32
+ * xmds = newClient;
33
+ * });
34
+ * }
27
35
  */
28
36
 
29
37
  import { createLogger } from '@xiboplayer/utils';
@@ -31,8 +39,30 @@ import { assertCmsClient } from './cms-client.js';
31
39
 
32
40
  const log = createLogger('Protocol');
33
41
 
34
- /** Default probe timeout in milliseconds */
35
- const PROBE_TIMEOUT_MS = 3000;
42
+ /**
43
+ * Default timeout for the FIRST probe at startup.
44
+ * Intentionally generous: a cold CMS has to warm up PHP-FPM workers,
45
+ * negotiate TLS, populate OpCache, and possibly fill MariaDB query plan
46
+ * caches. Empirically a healthy cold CMS can take 5-6 seconds on the
47
+ * very first request after a restart — the old 3000ms default was too
48
+ * tight and would lock the player into XMDS fallback on cold start.
49
+ */
50
+ const FIRST_PROBE_TIMEOUT_MS = 10000;
51
+
52
+ /**
53
+ * Default timeout for re-probes after fallback is engaged.
54
+ * Shorter than the first probe because by the time we're re-probing we
55
+ * already know the CMS was reachable at some point — either it's up
56
+ * now or it isn't.
57
+ */
58
+ const REPROBE_TIMEOUT_MS = 5000;
59
+
60
+ /** Starting delay before the first auto-reprobe after XMDS fallback. */
61
+ const REPROBE_MIN_DELAY_MS = 5000;
62
+ /** Ceiling delay for the backoff — we re-probe at least this often forever. */
63
+ const REPROBE_MAX_DELAY_MS = 120000;
64
+ /** Exponential factor applied after each failed reprobe. */
65
+ const REPROBE_BACKOFF_FACTOR = 2;
36
66
 
37
67
  export class ProtocolDetector {
38
68
  /**
@@ -40,29 +70,50 @@ export class ProtocolDetector {
40
70
  * @param {typeof import('./rest-client.js').RestClient} RestClientClass - RestClient constructor
41
71
  * @param {typeof import('./xmds-client.js').XmdsClient} XmdsClientClass - XmdsClient constructor
42
72
  * @param {Object} [options]
43
- * @param {number} [options.probeTimeoutMs=3000] - Timeout for health probe
73
+ * @param {number} [options.firstProbeTimeoutMs=10000] - Timeout for the initial probe
74
+ * @param {number} [options.reprobeTimeoutMs=5000] - Timeout for re-probes after fallback
75
+ * @param {number} [options.probeTimeoutMs] - Back-compat: sets BOTH first and reprobe timeouts to the same value
76
+ * @param {number} [options.reprobeMinDelayMs=5000] - Initial delay before the first auto-reprobe
77
+ * @param {number} [options.reprobeMaxDelayMs=120000] - Ceiling delay between auto-reprobes
44
78
  */
45
79
  constructor(cmsUrl, RestClientClass, XmdsClientClass, options = {}) {
46
80
  this.cmsUrl = cmsUrl;
47
81
  this.RestClient = RestClientClass;
48
82
  this.XmdsClient = XmdsClientClass;
49
- this.probeTimeoutMs = options.probeTimeoutMs || PROBE_TIMEOUT_MS;
83
+
84
+ // Back-compat: if a single probeTimeoutMs is provided, use it for both
85
+ // first and reprobe. New callers should pass the split options.
86
+ const legacy = options.probeTimeoutMs;
87
+ this.firstProbeTimeoutMs = options.firstProbeTimeoutMs ?? legacy ?? FIRST_PROBE_TIMEOUT_MS;
88
+ this.reprobeTimeoutMs = options.reprobeTimeoutMs ?? legacy ?? REPROBE_TIMEOUT_MS;
89
+
90
+ this.reprobeMinDelayMs = options.reprobeMinDelayMs ?? REPROBE_MIN_DELAY_MS;
91
+ this.reprobeMaxDelayMs = options.reprobeMaxDelayMs ?? REPROBE_MAX_DELAY_MS;
50
92
 
51
93
  /** @type {'rest'|'xmds'|null} Detected protocol (null = not yet probed) */
52
94
  this.protocol = null;
53
95
 
54
- /** @type {number} Timestamp of last successful probe */
96
+ /** @type {number} Timestamp of last probe attempt */
55
97
  this.lastProbeTime = 0;
98
+
99
+ /** @type {ReturnType<typeof setTimeout>|null} Scheduled auto-reprobe timer */
100
+ this._reprobeTimer = null;
101
+
102
+ /** @type {number} Current backoff delay (ms) — resets on successful probe or stop */
103
+ this._reprobeDelay = this.reprobeMinDelayMs;
56
104
  }
57
105
 
58
106
  /**
59
107
  * Probe the CMS health endpoint to determine protocol availability.
108
+ * @param {Object} [opts]
109
+ * @param {boolean} [opts.first=false] - Use the longer first-probe timeout
60
110
  * @returns {Promise<boolean>} true if REST/PlayerRestApi is available
61
111
  */
62
- async probe() {
112
+ async probe(opts = {}) {
113
+ const timeoutMs = opts.first ? this.firstProbeTimeoutMs : this.reprobeTimeoutMs;
63
114
  const available = await this.RestClient.isAvailable(this.cmsUrl, {
64
115
  maxRetries: 0,
65
- timeoutMs: this.probeTimeoutMs,
116
+ timeoutMs,
66
117
  });
67
118
  this.lastProbeTime = Date.now();
68
119
  return available;
@@ -70,8 +121,8 @@ export class ProtocolDetector {
70
121
 
71
122
  /**
72
123
  * Detect the best protocol and create the appropriate client.
73
- * On first call, probes the CMS. On subsequent calls, returns the cached
74
- * protocol unless reprobe() is called.
124
+ * On first call, probes the CMS with the generous first-probe timeout.
125
+ * On subsequent calls, returns the cached protocol unless reprobe() is called.
75
126
  *
76
127
  * @param {Object} config - Player configuration (passed to client constructor)
77
128
  * @param {string} [forceProtocol] - 'rest'|'xmds' to skip detection
@@ -95,10 +146,10 @@ export class ProtocolDetector {
95
146
  }
96
147
 
97
148
  // Auto-detect
98
- log.info('Probing CMS for REST API availability...');
149
+ log.info(`Probing CMS for REST API availability (timeout ${this.firstProbeTimeoutMs}ms)...`);
99
150
  let isRest = false;
100
151
  try {
101
- isRest = await this.probe();
152
+ isRest = await this.probe({ first: true });
102
153
  } catch (e) {
103
154
  log.warn('REST probe failed:', e?.message || e);
104
155
  }
@@ -112,15 +163,16 @@ export class ProtocolDetector {
112
163
  }
113
164
 
114
165
  this.protocol = 'xmds';
115
- log.info('REST unavailable — using XMDS/SOAP transport');
166
+ log.warn('REST unavailable — falling back to XMDS/SOAP transport');
116
167
  const client = new this.XmdsClient(config);
117
168
  assertCmsClient(client, 'XmdsClient');
118
169
  return { client, protocol: 'xmds' };
119
170
  }
120
171
 
121
172
  /**
122
- * Re-probe the CMS and potentially switch protocols.
123
- * Called on connection errors to check if the CMS was upgraded/downgraded.
173
+ * Re-probe the CMS once and potentially switch protocols.
174
+ * Called either externally on connection errors, or internally by the
175
+ * auto-reprobe loop.
124
176
  *
125
177
  * @param {Object} config - Player configuration
126
178
  * @returns {Promise<{client: any, protocol: 'rest'|'xmds', changed: boolean}>}
@@ -128,10 +180,9 @@ export class ProtocolDetector {
128
180
  async reprobe(config) {
129
181
  const previousProtocol = this.protocol;
130
182
 
131
- log.info('Re-probing CMS protocol...');
132
183
  let isRest = false;
133
184
  try {
134
- isRest = await this.probe();
185
+ isRest = await this.probe({ first: false });
135
186
  } catch (e) {
136
187
  log.warn('Re-probe failed:', e?.message || e);
137
188
  }
@@ -147,10 +198,88 @@ export class ProtocolDetector {
147
198
  return { client, protocol: newProtocol, changed: true };
148
199
  }
149
200
 
150
- log.info(`Protocol unchanged: ${newProtocol}`);
151
201
  return { client: null, protocol: newProtocol, changed: false };
152
202
  }
153
203
 
204
+ /**
205
+ * Start an automatic background re-probe loop while on XMDS fallback.
206
+ * Uses exponential backoff from reprobeMinDelayMs up to reprobeMaxDelayMs
207
+ * (defaults: 5s → 2min). Each failed reprobe logs a visibility warning
208
+ * that names the current fallback state and the next probe time, so an
209
+ * operator reading the log after any interval can see at a glance that
210
+ * the player is still running on the wrong transport.
211
+ *
212
+ * When a reprobe succeeds (detects REST is back), the callback is invoked
213
+ * with the new client and the loop stops. No-op if the current protocol
214
+ * is not 'xmds'.
215
+ *
216
+ * @param {Object} config - Player configuration passed to reprobe()
217
+ * @param {(client: any) => void} onRestPromoted - Called when REST recovers
218
+ */
219
+ startAutoReprobe(config, onRestPromoted) {
220
+ if (this.protocol !== 'xmds') {
221
+ // Only meaningful while in fallback
222
+ return;
223
+ }
224
+ // Cancel any existing timer before scheduling a fresh one
225
+ this.stopAutoReprobe();
226
+ this._reprobeDelay = this.reprobeMinDelayMs;
227
+
228
+ const schedule = () => {
229
+ const nextInMs = this._reprobeDelay;
230
+ this._reprobeTimer = setTimeout(async () => {
231
+ this._reprobeTimer = null;
232
+ let result;
233
+ try {
234
+ result = await this.reprobe(config);
235
+ } catch (e) {
236
+ log.warn('Auto-reprobe error:', e?.message || e);
237
+ result = { changed: false, protocol: 'xmds', client: null };
238
+ }
239
+
240
+ if (result.changed && result.protocol === 'rest') {
241
+ log.info('REST recovered — promoting back from XMDS fallback');
242
+ this._reprobeDelay = this.reprobeMinDelayMs;
243
+ try {
244
+ onRestPromoted(result.client);
245
+ } catch (e) {
246
+ log.warn('onRestPromoted callback threw:', e?.message || e);
247
+ }
248
+ return; // stop — we're back on REST
249
+ }
250
+
251
+ // Still on XMDS — back off and try again
252
+ const nextDelay = Math.min(
253
+ this._reprobeDelay * REPROBE_BACKOFF_FACTOR,
254
+ this.reprobeMaxDelayMs,
255
+ );
256
+ log.warn(
257
+ `Still on XMDS fallback — REST probe failed, next attempt in ${Math.round(nextDelay / 1000)}s`,
258
+ );
259
+ this._reprobeDelay = nextDelay;
260
+ schedule();
261
+ }, nextInMs);
262
+ };
263
+
264
+ log.info(
265
+ `Auto-reprobe scheduled — first attempt in ${Math.round(this._reprobeDelay / 1000)}s (backoff up to ${Math.round(this.reprobeMaxDelayMs / 1000)}s)`,
266
+ );
267
+ schedule();
268
+ }
269
+
270
+ /**
271
+ * Cancel any pending auto-reprobe timer. Safe to call multiple times.
272
+ * Call this when the player is shutting down, or when the protocol is
273
+ * swapped back to REST by an external caller.
274
+ */
275
+ stopAutoReprobe() {
276
+ if (this._reprobeTimer) {
277
+ clearTimeout(this._reprobeTimer);
278
+ this._reprobeTimer = null;
279
+ }
280
+ this._reprobeDelay = this.reprobeMinDelayMs;
281
+ }
282
+
154
283
  /**
155
284
  * Get the currently detected protocol.
156
285
  * @returns {'rest'|'xmds'|null}
@@ -1,16 +1,22 @@
1
1
  // SPDX-License-Identifier: AGPL-3.0-or-later
2
2
  // Copyright (c) 2024-2026 Pau Aliagas <linuxnow@gmail.com>
3
+ // @vitest-environment node
3
4
  /**
4
5
  * ProtocolDetector Tests
5
6
  *
6
7
  * Tests CMS protocol auto-detection logic:
7
- * - REST probing via RestClient.isAvailable()
8
+ * - REST probing via RestClient.isAvailable() with first/reprobe timeout split
8
9
  * - Fallback to XMDS/SOAP when REST is unavailable
9
10
  * - Forced protocol selection (bypass auto-detection)
10
11
  * - Re-probing on connection errors
12
+ * - Automatic background re-probe loop with exponential backoff
13
+ *
14
+ * Pinned to the node environment because this file only exercises JS
15
+ * logic + mocks — no DOM is needed, and jsdom's CJS require of
16
+ * @asamuzakjp/css-color has a top-level-await bug on current Node.
11
17
  */
12
18
 
13
- import { describe, it, expect, beforeEach, vi } from 'vitest';
19
+ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
14
20
  import { ProtocolDetector } from './protocol-detector.js';
15
21
  import { CMS_CLIENT_METHODS } from './cms-client.js';
16
22
 
@@ -59,9 +65,10 @@ describe('ProtocolDetector', () => {
59
65
  expect(protocol).toBe('rest');
60
66
  expect(client.type).toBe('rest');
61
67
  expect(detector.getProtocol()).toBe('rest');
68
+ // detect() triggers a FIRST probe with the generous firstProbeTimeoutMs
62
69
  expect(MockRestClient.isAvailable).toHaveBeenCalledWith('https://cms.example.com', {
63
70
  maxRetries: 0,
64
- timeoutMs: 3000,
71
+ timeoutMs: 10000,
65
72
  });
66
73
  });
67
74
 
@@ -117,9 +124,24 @@ describe('ProtocolDetector', () => {
117
124
  expect(client.config).toBe(config);
118
125
  });
119
126
 
120
- it('should use custom probe timeout', async () => {
127
+ it('should use the generous first-probe timeout by default', async () => {
128
+ MockRestClient.isAvailable.mockResolvedValue(true);
129
+
130
+ const detector = new ProtocolDetector('https://cms.example.com', MockRestClient, MockXmdsClient);
131
+ await detector.detect(config);
132
+
133
+ // detect() triggers the FIRST probe, which uses firstProbeTimeoutMs (10s)
134
+ // rather than the shorter reprobe timeout. A cold CMS needs headroom.
135
+ expect(MockRestClient.isAvailable).toHaveBeenCalledWith('https://cms.example.com', {
136
+ maxRetries: 0,
137
+ timeoutMs: 10000,
138
+ });
139
+ });
140
+
141
+ it('should honour back-compat probeTimeoutMs option', async () => {
121
142
  MockRestClient.isAvailable.mockResolvedValue(true);
122
143
 
144
+ // Single probeTimeoutMs sets BOTH first-probe and reprobe timeouts
123
145
  const detector = new ProtocolDetector('https://cms.example.com', MockRestClient, MockXmdsClient, {
124
146
  probeTimeoutMs: 5000,
125
147
  });
@@ -131,6 +153,26 @@ describe('ProtocolDetector', () => {
131
153
  });
132
154
  });
133
155
 
156
+ it('should honour split firstProbeTimeoutMs and reprobeTimeoutMs', async () => {
157
+ MockRestClient.isAvailable.mockResolvedValue(false);
158
+
159
+ const detector = new ProtocolDetector('https://cms.example.com', MockRestClient, MockXmdsClient, {
160
+ firstProbeTimeoutMs: 15000,
161
+ reprobeTimeoutMs: 2000,
162
+ });
163
+ await detector.detect(config); // first probe → 15000
164
+ await detector.reprobe(config); // reprobe → 2000
165
+
166
+ expect(MockRestClient.isAvailable).toHaveBeenNthCalledWith(1, 'https://cms.example.com', {
167
+ maxRetries: 0,
168
+ timeoutMs: 15000,
169
+ });
170
+ expect(MockRestClient.isAvailable).toHaveBeenNthCalledWith(2, 'https://cms.example.com', {
171
+ maxRetries: 0,
172
+ timeoutMs: 2000,
173
+ });
174
+ });
175
+
134
176
  it('should record lastProbeTime after detection', async () => {
135
177
  MockRestClient.isAvailable.mockResolvedValue(true);
136
178
 
@@ -223,15 +265,27 @@ describe('ProtocolDetector', () => {
223
265
  // ── probe() ──────────────────────────────────────────────────────
224
266
 
225
267
  describe('probe()', () => {
226
- it('should call RestClient.isAvailable with correct URL and timeout', async () => {
268
+ it('should default to the reprobe timeout (plain probe() = not first)', async () => {
227
269
  MockRestClient.isAvailable.mockResolvedValue(true);
228
270
  const detector = new ProtocolDetector('https://cms.example.com', MockRestClient, MockXmdsClient);
229
271
  const result = await detector.probe();
230
272
 
231
273
  expect(result).toBe(true);
274
+ // Plain probe() with no { first: true } uses the shorter reprobe timeout
275
+ expect(MockRestClient.isAvailable).toHaveBeenCalledWith('https://cms.example.com', {
276
+ maxRetries: 0,
277
+ timeoutMs: 5000,
278
+ });
279
+ });
280
+
281
+ it('should use firstProbeTimeoutMs when called with { first: true }', async () => {
282
+ MockRestClient.isAvailable.mockResolvedValue(true);
283
+ const detector = new ProtocolDetector('https://cms.example.com', MockRestClient, MockXmdsClient);
284
+ await detector.probe({ first: true });
285
+
232
286
  expect(MockRestClient.isAvailable).toHaveBeenCalledWith('https://cms.example.com', {
233
287
  maxRetries: 0,
234
- timeoutMs: 3000,
288
+ timeoutMs: 10000,
235
289
  });
236
290
  });
237
291
 
@@ -243,4 +297,124 @@ describe('ProtocolDetector', () => {
243
297
  expect(result).toBe(false);
244
298
  });
245
299
  });
300
+
301
+ // ── startAutoReprobe() / stopAutoReprobe() ───────────────────────
302
+
303
+ describe('startAutoReprobe()', () => {
304
+ beforeEach(() => {
305
+ vi.useFakeTimers();
306
+ });
307
+
308
+ afterEach(() => {
309
+ vi.useRealTimers();
310
+ });
311
+
312
+ it('should be a no-op when protocol is not xmds', async () => {
313
+ MockRestClient.isAvailable.mockResolvedValue(true);
314
+ const detector = new ProtocolDetector('https://cms.example.com', MockRestClient, MockXmdsClient);
315
+ await detector.detect(config);
316
+ expect(detector.getProtocol()).toBe('rest');
317
+
318
+ const onPromoted = vi.fn();
319
+ detector.startAutoReprobe(config, onPromoted);
320
+
321
+ // No timer should be scheduled
322
+ expect(detector._reprobeTimer).toBeNull();
323
+ vi.advanceTimersByTime(60000);
324
+ expect(onPromoted).not.toHaveBeenCalled();
325
+ });
326
+
327
+ it('should promote back to REST and call onRestPromoted when probe succeeds', async () => {
328
+ MockRestClient.isAvailable.mockResolvedValueOnce(false); // initial detect → xmds
329
+ const detector = new ProtocolDetector('https://cms.example.com', MockRestClient, MockXmdsClient);
330
+ await detector.detect(config);
331
+ expect(detector.getProtocol()).toBe('xmds');
332
+
333
+ MockRestClient.isAvailable.mockResolvedValueOnce(true); // reprobe → rest
334
+ const onPromoted = vi.fn();
335
+ detector.startAutoReprobe(config, onPromoted);
336
+
337
+ // First auto-reprobe fires after reprobeMinDelayMs (5s default)
338
+ await vi.advanceTimersByTimeAsync(5000);
339
+
340
+ expect(onPromoted).toHaveBeenCalledTimes(1);
341
+ expect(onPromoted).toHaveBeenCalledWith(expect.objectContaining({ type: 'rest' }));
342
+ expect(detector.getProtocol()).toBe('rest');
343
+ // Timer is cleared after a successful promotion
344
+ expect(detector._reprobeTimer).toBeNull();
345
+ });
346
+
347
+ it('should follow exponential backoff while REST stays unavailable', async () => {
348
+ MockRestClient.isAvailable.mockResolvedValue(false); // always fails
349
+ const detector = new ProtocolDetector('https://cms.example.com', MockRestClient, MockXmdsClient, {
350
+ reprobeMinDelayMs: 1000,
351
+ reprobeMaxDelayMs: 8000,
352
+ });
353
+ await detector.detect(config);
354
+
355
+ const onPromoted = vi.fn();
356
+ detector.startAutoReprobe(config, onPromoted);
357
+
358
+ // 1st attempt at 1s
359
+ expect(MockRestClient.isAvailable).toHaveBeenCalledTimes(1); // from detect()
360
+ await vi.advanceTimersByTimeAsync(1000);
361
+ expect(MockRestClient.isAvailable).toHaveBeenCalledTimes(2);
362
+
363
+ // 2nd attempt at 1 + 2 = 3s (doubled)
364
+ await vi.advanceTimersByTimeAsync(2000);
365
+ expect(MockRestClient.isAvailable).toHaveBeenCalledTimes(3);
366
+
367
+ // 3rd attempt at 3 + 4 = 7s
368
+ await vi.advanceTimersByTimeAsync(4000);
369
+ expect(MockRestClient.isAvailable).toHaveBeenCalledTimes(4);
370
+
371
+ // 4th attempt at 7 + 8 = 15s (capped at max)
372
+ await vi.advanceTimersByTimeAsync(8000);
373
+ expect(MockRestClient.isAvailable).toHaveBeenCalledTimes(5);
374
+
375
+ // 5th at +8s (still capped)
376
+ await vi.advanceTimersByTimeAsync(8000);
377
+ expect(MockRestClient.isAvailable).toHaveBeenCalledTimes(6);
378
+
379
+ expect(onPromoted).not.toHaveBeenCalled();
380
+ detector.stopAutoReprobe();
381
+ });
382
+
383
+ it('should cancel the pending timer when stopAutoReprobe is called', async () => {
384
+ MockRestClient.isAvailable.mockResolvedValue(false);
385
+ const detector = new ProtocolDetector('https://cms.example.com', MockRestClient, MockXmdsClient);
386
+ await detector.detect(config);
387
+
388
+ const onPromoted = vi.fn();
389
+ detector.startAutoReprobe(config, onPromoted);
390
+ expect(detector._reprobeTimer).not.toBeNull();
391
+
392
+ detector.stopAutoReprobe();
393
+ expect(detector._reprobeTimer).toBeNull();
394
+
395
+ // Advancing the clock should trigger no further calls
396
+ const callsBefore = MockRestClient.isAvailable.mock.calls.length;
397
+ await vi.advanceTimersByTimeAsync(300000); // 5 minutes
398
+ expect(MockRestClient.isAvailable.mock.calls.length).toBe(callsBefore);
399
+ expect(onPromoted).not.toHaveBeenCalled();
400
+ });
401
+
402
+ it('should restart cleanly when startAutoReprobe is called twice', async () => {
403
+ MockRestClient.isAvailable.mockResolvedValue(false);
404
+ const detector = new ProtocolDetector('https://cms.example.com', MockRestClient, MockXmdsClient, {
405
+ reprobeMinDelayMs: 1000,
406
+ reprobeMaxDelayMs: 4000,
407
+ });
408
+ await detector.detect(config);
409
+
410
+ const onPromoted = vi.fn();
411
+ detector.startAutoReprobe(config, onPromoted);
412
+ // Advance once so delay doubles internally
413
+ await vi.advanceTimersByTimeAsync(1000);
414
+
415
+ // Restart — delay should reset to minimum
416
+ detector.startAutoReprobe(config, onPromoted);
417
+ expect(detector._reprobeDelay).toBe(1000);
418
+ });
419
+ });
246
420
  });
@@ -250,7 +250,7 @@ export class RestClient {
250
250
  clientCode: this.config.clientCode || 400,
251
251
  operatingSystem: os,
252
252
  macAddress: this.config.macAddress || 'n/a',
253
- xmrChannel: this.config.xmrChannel,
253
+ xmrChannel: this.config.xmrChannel || '',
254
254
  xmrPubKey: this.config.xmrPubKey || '',
255
255
  });
256
256