@ecobridge.xyz/devicemanager 3.1.0 → 3.1.1

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 (35) hide show
  1. package/.gitea/workflows/default_tags.yaml +0 -21
  2. package/.smartconfig.json +69 -0
  3. package/changelog.md +13 -1
  4. package/dist_ts/00_commitinfo_data.js +1 -1
  5. package/dist_ts/devicemanager.classes.devicemanager.js +33 -7
  6. package/dist_ts/discovery/discovery.classes.mdns.js +46 -11
  7. package/dist_ts/discovery/discovery.classes.networkscanner.d.ts +1 -1
  8. package/dist_ts/discovery/discovery.classes.networkscanner.js +1 -1
  9. package/dist_ts/discovery/discovery.classes.ssdp.d.ts +52 -21
  10. package/dist_ts/discovery/discovery.classes.ssdp.js +407 -117
  11. package/dist_ts/index.d.ts +1 -1
  12. package/dist_ts/interfaces/feature.interfaces.d.ts +1 -1
  13. package/dist_ts/interfaces/index.d.ts +1 -1
  14. package/dist_ts/plugins.d.ts +5 -15
  15. package/dist_ts/plugins.js +6 -19
  16. package/dist_ts/protocols/protocol.escl.js +2 -2
  17. package/license.md +21 -0
  18. package/package.json +16 -25
  19. package/pnpm-workspace.yaml +4 -0
  20. package/readme.md +61 -76
  21. package/test/{test.ts → test.node.ts} +20 -45
  22. package/test/test.ssdp.node.ts +978 -0
  23. package/ts/00_commitinfo_data.ts +1 -1
  24. package/ts/devicemanager.classes.devicemanager.ts +44 -6
  25. package/ts/discovery/discovery.classes.mdns.ts +40 -11
  26. package/ts/discovery/discovery.classes.networkscanner.ts +1 -1
  27. package/ts/discovery/discovery.classes.ssdp.ts +493 -130
  28. package/ts/index.ts +1 -0
  29. package/ts/interfaces/feature.interfaces.ts +1 -1
  30. package/ts/interfaces/index.ts +1 -1
  31. package/ts/plugins.ts +5 -25
  32. package/ts/protocols/protocol.escl.ts +1 -1
  33. package/dist_ts/protocols/protocol.ipp.old.d.ts +0 -45
  34. package/dist_ts/protocols/protocol.ipp.old.js +0 -284
  35. package/npmextra.json +0 -24
@@ -0,0 +1,978 @@
1
+ import { EventEmitter } from 'node:events';
2
+ import type { BindOptions, RemoteInfo, Socket } from 'node:dgram';
3
+ import { createServer } from 'node:http';
4
+ import type { NetworkInterfaceInfo } from 'node:os';
5
+
6
+ import { expect, tap } from '@git.zone/tstest/tapbundle';
7
+ import * as devicemanager from '../ts/index.js';
8
+ import type {
9
+ ISsdpDevice,
10
+ ISsdpDiscoveryDependencies,
11
+ } from '../ts/index.js';
12
+
13
+ const DEVICE_DESCRIPTION = `<?xml version="1.0"?>
14
+ <root>
15
+ <device>
16
+ <deviceType>urn:schemas-upnp-org:device:MediaRenderer:1</deviceType>
17
+ <friendlyName>Living Room</friendlyName>
18
+ <manufacturer>Example Corp</manufacturer>
19
+ <modelName>Example Renderer</modelName>
20
+ <UDN>uuid:device-1</UDN>
21
+ <serviceList>
22
+ <service>
23
+ <serviceType>urn:schemas-upnp-org:service:AVTransport:1</serviceType>
24
+ <serviceId>urn:upnp-org:serviceId:AVTransport</serviceId>
25
+ <SCPDURL>/avtransport.xml</SCPDURL>
26
+ <controlURL>/control</controlURL>
27
+ <eventSubURL>/events</eventSubURL>
28
+ </service>
29
+ </serviceList>
30
+ <iconList>
31
+ <icon>
32
+ <mimetype>image/png</mimetype>
33
+ <width>64</width>
34
+ <height>64</height>
35
+ <depth>24</depth>
36
+ <url>/icon.png</url>
37
+ </icon>
38
+ </iconList>
39
+ </device>
40
+ </root>`;
41
+
42
+ const SONOS_DEVICE_DESCRIPTION = `<?xml version="1.0"?>
43
+ <root>
44
+ <device>
45
+ <deviceType>urn:schemas-upnp-org:device:ZonePlayer:1</deviceType>
46
+ <friendlyName>Kitchen</friendlyName>
47
+ <manufacturer>Sonos, Inc.</manufacturer>
48
+ <modelName>Sonos One</modelName>
49
+ <UDN>uuid:RINCON_00000000000101400</UDN>
50
+ </device>
51
+ </root>`;
52
+
53
+ interface IFakeSocketOptions {
54
+ bindError?: Error;
55
+ configurationError?: Error;
56
+ sendErrors?: Array<Error | null>;
57
+ }
58
+
59
+ interface ISentPacket {
60
+ message: string;
61
+ port: number;
62
+ address: string;
63
+ wasBound: boolean;
64
+ }
65
+
66
+ class FakeSocket extends EventEmitter {
67
+ public readonly bindCalls: BindOptions[] = [];
68
+ public readonly sentPackets: ISentPacket[] = [];
69
+ public multicastInterface: string | null = null;
70
+ public multicastTtl: number | null = null;
71
+ public bound = false;
72
+ public closed = false;
73
+ public unrefCalled = false;
74
+ private readonly options: IFakeSocketOptions;
75
+
76
+ constructor(options: IFakeSocketOptions = {}) {
77
+ super();
78
+ this.options = options;
79
+ }
80
+
81
+ public bind(options: BindOptions): this {
82
+ this.bindCalls.push(options);
83
+ queueMicrotask(() => {
84
+ if (this.options.bindError) {
85
+ this.emit('error', this.options.bindError);
86
+ } else {
87
+ this.bound = true;
88
+ this.emit('listening');
89
+ }
90
+ });
91
+ return this;
92
+ }
93
+
94
+ public setMulticastInterface(address: string): void {
95
+ if (this.options.configurationError) {
96
+ throw this.options.configurationError;
97
+ }
98
+ this.multicastInterface = address;
99
+ }
100
+
101
+ public setMulticastTTL(ttl: number): void {
102
+ this.multicastTtl = ttl;
103
+ }
104
+
105
+ public unref(): this {
106
+ this.unrefCalled = true;
107
+ return this;
108
+ }
109
+
110
+ public send(
111
+ message: Buffer,
112
+ port: number,
113
+ address: string,
114
+ callback: (error: Error | null, bytes: number) => void
115
+ ): void {
116
+ this.sentPackets.push({
117
+ message: message.toString('ascii'),
118
+ port,
119
+ address,
120
+ wasBound: this.bound,
121
+ });
122
+ const error = this.options.sendErrors?.shift() ?? null;
123
+ queueMicrotask(() => callback(error, error ? 0 : message.length));
124
+ }
125
+
126
+ public close(callback?: () => void): this {
127
+ this.closed = true;
128
+ queueMicrotask(() => {
129
+ this.emit('close');
130
+ callback?.();
131
+ });
132
+ return this;
133
+ }
134
+
135
+ public emitMessage(message: string, address = '192.168.1.50', port = 1900): void {
136
+ const rinfo: RemoteInfo = {
137
+ address,
138
+ family: 'IPv4',
139
+ port,
140
+ size: Buffer.byteLength(message),
141
+ };
142
+ this.emit('message', Buffer.from(message), rinfo);
143
+ }
144
+ }
145
+
146
+ type TFakeTimerKind = 'interval' | 'timeout';
147
+
148
+ class FakeTimer {
149
+ public active = true;
150
+ public unrefCalled = false;
151
+
152
+ constructor(
153
+ public readonly kind: TFakeTimerKind,
154
+ public readonly callback: () => void,
155
+ public readonly delay: number
156
+ ) {}
157
+
158
+ public unref(): void {
159
+ this.unrefCalled = true;
160
+ }
161
+ }
162
+
163
+ class FakeClock {
164
+ public readonly timers: FakeTimer[] = [];
165
+
166
+ public readonly setInterval = (callback: () => void, delay: number): ReturnType<typeof setInterval> => {
167
+ return this.createTimer('interval', callback, delay);
168
+ };
169
+
170
+ public readonly clearInterval = (timer: ReturnType<typeof setInterval>): void => {
171
+ this.toFakeTimer(timer).active = false;
172
+ };
173
+
174
+ public readonly setTimeout = (callback: () => void, delay: number): ReturnType<typeof setTimeout> => {
175
+ return this.createTimer('timeout', callback, delay);
176
+ };
177
+
178
+ public readonly clearTimeout = (timer: ReturnType<typeof setTimeout>): void => {
179
+ this.toFakeTimer(timer).active = false;
180
+ };
181
+
182
+ public fireIntervals(): void {
183
+ for (const timer of this.timers) {
184
+ if (timer.active && timer.kind === 'interval') {
185
+ timer.callback();
186
+ }
187
+ }
188
+ }
189
+
190
+ public get activeCount(): number {
191
+ return this.timers.filter((timer) => timer.active).length;
192
+ }
193
+
194
+ private createTimer(
195
+ kind: TFakeTimerKind,
196
+ callback: () => void,
197
+ delay: number
198
+ ): ReturnType<typeof setTimeout> {
199
+ const timer = new FakeTimer(kind, callback, delay);
200
+ this.timers.push(timer);
201
+ return timer as unknown as ReturnType<typeof setTimeout>;
202
+ }
203
+
204
+ private toFakeTimer(timer: ReturnType<typeof setTimeout>): FakeTimer {
205
+ return timer as unknown as FakeTimer;
206
+ }
207
+ }
208
+
209
+ interface ITestHarnessOptions {
210
+ addresses?: string[];
211
+ socketOptions?: IFakeSocketOptions[];
212
+ fetch?: typeof globalThis.fetch;
213
+ }
214
+
215
+ interface ITestHarness {
216
+ discovery: devicemanager.SsdpDiscovery;
217
+ sockets: FakeSocket[];
218
+ clock: FakeClock;
219
+ fetchCalls: Array<{ input: string; init?: RequestInit }>;
220
+ }
221
+
222
+ function createHarness(options: ITestHarnessOptions = {}): ITestHarness {
223
+ const addresses = options.addresses ?? ['192.168.1.10'];
224
+ const sockets: FakeSocket[] = [];
225
+ const clock = new FakeClock();
226
+ const fetchCalls: Array<{ input: string; init?: RequestInit }> = [];
227
+ let socketIndex = 0;
228
+
229
+ const fetchImplementation: typeof globalThis.fetch = async (input, init) => {
230
+ fetchCalls.push({ input: String(input), init });
231
+ if (options.fetch) {
232
+ return options.fetch(input, init);
233
+ }
234
+ return new Response(DEVICE_DESCRIPTION, { status: 200 });
235
+ };
236
+
237
+ const interfaceInfos: NodeJS.Dict<NetworkInterfaceInfo[]> = {
238
+ lo: [{
239
+ address: '127.0.0.1',
240
+ netmask: '255.0.0.0',
241
+ family: 'IPv4',
242
+ mac: '00:00:00:00:00:00',
243
+ internal: true,
244
+ cidr: '127.0.0.1/8',
245
+ }],
246
+ ipv6: [{
247
+ address: 'fe80::1',
248
+ netmask: 'ffff:ffff:ffff:ffff::',
249
+ family: 'IPv6',
250
+ mac: '00:00:00:00:00:01',
251
+ internal: false,
252
+ cidr: 'fe80::1/64',
253
+ scopeid: 1,
254
+ }],
255
+ };
256
+
257
+ addresses.forEach((address, index) => {
258
+ interfaceInfos[`eth${index}`] = [{
259
+ address,
260
+ netmask: '255.255.255.0',
261
+ family: 'IPv4',
262
+ mac: `00:00:00:00:00:${String(index + 2).padStart(2, '0')}`,
263
+ internal: false,
264
+ cidr: `${address}/24`,
265
+ }];
266
+ });
267
+ if (addresses.length > 0) {
268
+ interfaceInfos.duplicate = [interfaceInfos.eth0![0]];
269
+ }
270
+
271
+ const dependencies: ISsdpDiscoveryDependencies = {
272
+ createSocket: () => {
273
+ const socket = new FakeSocket(options.socketOptions?.[socketIndex]);
274
+ socketIndex++;
275
+ sockets.push(socket);
276
+ return socket as unknown as Socket;
277
+ },
278
+ networkInterfaces: () => interfaceInfos,
279
+ fetch: fetchImplementation,
280
+ setInterval: clock.setInterval,
281
+ clearInterval: clock.clearInterval,
282
+ setTimeout: clock.setTimeout,
283
+ clearTimeout: clock.clearTimeout,
284
+ };
285
+
286
+ return {
287
+ discovery: new devicemanager.SsdpDiscovery(dependencies),
288
+ sockets,
289
+ clock,
290
+ fetchCalls,
291
+ };
292
+ }
293
+
294
+ function createSsdpResponse(
295
+ usn = 'uuid:device-1::upnp:rootdevice',
296
+ location = 'http://192.168.1.50:1400/description.xml',
297
+ serviceType = devicemanager.SSDP_SERVICE_TYPES.MEDIA_RENDERER,
298
+ lineEnding = '\r\n'
299
+ ): string {
300
+ return [
301
+ 'HTTP/1.1 200 OK',
302
+ `usn: ${usn}`,
303
+ `LoCaTiOn: ${location}`,
304
+ `sT: ${serviceType}`,
305
+ 'cache-control: max-age=1800',
306
+ '',
307
+ '',
308
+ ].join(lineEnding);
309
+ }
310
+
311
+ async function flushAsyncWork(): Promise<void> {
312
+ await new Promise<void>((resolve) => setImmediate(resolve));
313
+ }
314
+
315
+ async function captureError(operation: Promise<unknown>): Promise<Error | null> {
316
+ try {
317
+ await operation;
318
+ return null;
319
+ } catch (error) {
320
+ return error instanceof Error ? error : new Error(String(error));
321
+ }
322
+ }
323
+
324
+ tap.test('SSDP frames and parses packets across every non-internal IPv4 interface', async () => {
325
+ const harness = createHarness({
326
+ addresses: ['192.168.1.10', '10.0.0.20', '192.168.1.10'],
327
+ });
328
+ const foundDevices: ISsdpDevice[] = [];
329
+ let started = 0;
330
+ let stopped = 0;
331
+ harness.discovery.on('started', () => started++);
332
+ harness.discovery.on('stopped', () => stopped++);
333
+ harness.discovery.on('device:found', (device: ISsdpDevice) => foundDevices.push(device));
334
+
335
+ await harness.discovery.start();
336
+
337
+ expect(harness.discovery.isRunning).toEqual(true);
338
+ expect(started).toEqual(1);
339
+ expect(harness.sockets.length).toEqual(2);
340
+ expect(harness.sockets.map((socket) => socket.bindCalls[0].address)).toEqual([
341
+ '10.0.0.20',
342
+ '192.168.1.10',
343
+ ]);
344
+ expect(harness.sockets.map((socket) => socket.multicastInterface)).toEqual([
345
+ '10.0.0.20',
346
+ '192.168.1.10',
347
+ ]);
348
+ expect(harness.sockets.every((socket) => socket.multicastTtl === 4)).toEqual(true);
349
+ expect(harness.sockets.every((socket) => socket.unrefCalled)).toEqual(true);
350
+ expect(harness.sockets.every((socket) => socket.sentPackets.length === 4)).toEqual(true);
351
+ expect(harness.sockets.every((socket) => socket.sentPackets.every((packet) => packet.wasBound))).toEqual(true);
352
+ expect(harness.sockets[0].sentPackets[0]).toEqual({
353
+ message: 'M-SEARCH * HTTP/1.1\r\nHOST: 239.255.255.250:1900\r\nMAN: "ssdp:discover"\r\nMX: 3\r\nST: upnp:rootdevice\r\n\r\n',
354
+ port: 1900,
355
+ address: '239.255.255.250',
356
+ wasBound: true,
357
+ });
358
+ expect(harness.clock.timers[0].unrefCalled).toEqual(true);
359
+
360
+ harness.sockets[0].emitMessage('HTTP/1.1 404 Not Found\r\nUSN: ignored\r\n\r\n');
361
+ harness.sockets[0].emitMessage(createSsdpResponse(undefined, undefined, undefined, '\n'));
362
+ await flushAsyncWork();
363
+
364
+ expect(foundDevices.length).toEqual(1);
365
+ expect(harness.discovery.getDevices().length).toEqual(1);
366
+ expect(harness.discovery.getDevicesByType(devicemanager.SSDP_SERVICE_TYPES.MEDIA_RENDERER).length).toEqual(1);
367
+ expect(foundDevices[0].headers).toEqual({
368
+ USN: 'uuid:device-1::upnp:rootdevice',
369
+ LOCATION: 'http://192.168.1.50:1400/description.xml',
370
+ ST: devicemanager.SSDP_SERVICE_TYPES.MEDIA_RENDERER,
371
+ 'CACHE-CONTROL': 'max-age=1800',
372
+ });
373
+ expect(foundDevices[0].address).toEqual('192.168.1.50');
374
+ expect(foundDevices[0].port).toEqual(1400);
375
+ expect(foundDevices[0].description?.friendlyName).toEqual('Living Room');
376
+ expect(foundDevices[0].description?.services[0].controlURL).toEqual('/control');
377
+ expect(foundDevices[0].description?.icons?.[0].width).toEqual(64);
378
+
379
+ await harness.discovery.stop();
380
+ expect(harness.discovery.isRunning).toEqual(false);
381
+ expect(stopped).toEqual(1);
382
+ expect(harness.clock.activeCount).toEqual(0);
383
+ expect(harness.sockets.every((socket) => socket.closed)).toEqual(true);
384
+ expect(harness.sockets.every((socket) => socket.listenerCount('message') === 0)).toEqual(true);
385
+ expect(harness.sockets.every((socket) => socket.listenerCount('error') === 0)).toEqual(true);
386
+ });
387
+
388
+ tap.test('SSDP deduplicates USNs and preserves event ordering across custom, periodic, and restart searches', async () => {
389
+ let resolveDescription!: (response: Response) => void;
390
+ const harness = createHarness({
391
+ fetch: async () => new Promise<Response>((resolve) => {
392
+ resolveDescription = resolve;
393
+ }),
394
+ });
395
+ const eventNames: string[] = [];
396
+ const foundDevices: ISsdpDevice[] = [];
397
+ const updatedDevices: ISsdpDevice[] = [];
398
+ harness.discovery.on('started', () => eventNames.push('started'));
399
+ harness.discovery.on('stopped', () => eventNames.push('stopped'));
400
+ harness.discovery.on('device:found', (device: ISsdpDevice) => {
401
+ eventNames.push('found');
402
+ foundDevices.push(device);
403
+ });
404
+ harness.discovery.on('device:updated', (device: ISsdpDevice) => {
405
+ eventNames.push('updated');
406
+ updatedDevices.push(device);
407
+ });
408
+
409
+ await harness.discovery.start([devicemanager.SSDP_SERVICE_TYPES.ROOT]);
410
+ harness.sockets[0].emitMessage(createSsdpResponse());
411
+ harness.sockets[0].emitMessage(createSsdpResponse(
412
+ undefined,
413
+ 'https://192.168.1.50:1443/description.xml'
414
+ ));
415
+ expect(foundDevices.length).toEqual(0);
416
+ expect(updatedDevices.length).toEqual(0);
417
+ expect(harness.discovery.getDevices().length).toEqual(1);
418
+
419
+ resolveDescription(new Response(DEVICE_DESCRIPTION, { status: 200 }));
420
+ await flushAsyncWork();
421
+ const canonicalDevice = foundDevices[0];
422
+ expect(canonicalDevice.port).toEqual(1443);
423
+
424
+ harness.sockets[0].emitMessage(createSsdpResponse());
425
+ expect(updatedDevices.length).toEqual(1);
426
+ expect(updatedDevices[0] === canonicalDevice).toEqual(true);
427
+ expect(updatedDevices[0].description?.friendlyName).toEqual('Living Room');
428
+ expect(updatedDevices[0].port).toEqual(1400);
429
+ expect(harness.fetchCalls.length).toEqual(1);
430
+
431
+ harness.discovery.search(devicemanager.SSDP_SERVICE_TYPES.BASIC_DEVICE);
432
+ await flushAsyncWork();
433
+ expect(harness.sockets[0].sentPackets.length).toEqual(2);
434
+ expect(harness.sockets[0].sentPackets[1].message).toInclude(
435
+ `ST: ${devicemanager.SSDP_SERVICE_TYPES.BASIC_DEVICE}\r\n`
436
+ );
437
+
438
+ harness.clock.fireIntervals();
439
+ await flushAsyncWork();
440
+ expect(harness.sockets[0].sentPackets.length).toEqual(3);
441
+ expect(harness.sockets[0].sentPackets[2].message).toInclude(
442
+ `ST: ${devicemanager.SSDP_SERVICE_TYPES.ROOT}\r\n`
443
+ );
444
+
445
+ await harness.discovery.start([devicemanager.SSDP_SERVICE_TYPES.MEDIA_SERVER]);
446
+ expect(harness.sockets.length).toEqual(1);
447
+ await harness.discovery.stop();
448
+ await harness.discovery.stop();
449
+
450
+ await harness.discovery.start([devicemanager.SSDP_SERVICE_TYPES.ROOT]);
451
+ expect(harness.sockets.length).toEqual(2);
452
+ harness.sockets[1].emitMessage(createSsdpResponse());
453
+ expect(updatedDevices.length).toEqual(2);
454
+ expect(updatedDevices[1] === canonicalDevice).toEqual(true);
455
+ expect(harness.fetchCalls.length).toEqual(1);
456
+ await harness.discovery.stop();
457
+
458
+ expect(eventNames).toEqual([
459
+ 'started',
460
+ 'found',
461
+ 'updated',
462
+ 'stopped',
463
+ 'started',
464
+ 'updated',
465
+ 'stopped',
466
+ ]);
467
+ expect(harness.clock.activeCount).toEqual(0);
468
+ });
469
+
470
+ tap.test('SSDP rolls back zero-interface, partial bind, configuration, and initial send failures', async () => {
471
+ const zeroInterfaceHarness = createHarness({ addresses: [] });
472
+ const zeroInterfaceError = await captureError(zeroInterfaceHarness.discovery.start());
473
+ expect(zeroInterfaceError?.message).toContain('No non-internal IPv4 interfaces');
474
+ expect(zeroInterfaceHarness.discovery.isRunning).toEqual(false);
475
+ expect(zeroInterfaceHarness.sockets.length).toEqual(0);
476
+
477
+ const bindHarness = createHarness({
478
+ addresses: ['10.0.0.1', '10.0.0.2'],
479
+ socketOptions: [{}, { bindError: new Error('bind failed') }],
480
+ });
481
+ const bindError = await captureError(bindHarness.discovery.start([
482
+ devicemanager.SSDP_SERVICE_TYPES.ROOT,
483
+ ]));
484
+ expect(bindError?.message).toEqual('bind failed');
485
+ expect(bindHarness.discovery.isRunning).toEqual(false);
486
+ expect(bindHarness.sockets.every((socket) => socket.closed)).toEqual(true);
487
+ expect(bindHarness.sockets.every((socket) => socket.listenerCount('message') === 0)).toEqual(true);
488
+ expect(bindHarness.sockets.every((socket) => socket.listenerCount('error') === 0)).toEqual(true);
489
+ expect(bindHarness.clock.activeCount).toEqual(0);
490
+
491
+ const configurationHarness = createHarness({
492
+ socketOptions: [{ configurationError: new Error('configuration failed') }],
493
+ });
494
+ const configurationError = await captureError(configurationHarness.discovery.start());
495
+ expect(configurationError?.message).toEqual('configuration failed');
496
+ expect(configurationHarness.sockets[0].closed).toEqual(true);
497
+
498
+ const sendHarness = createHarness({
499
+ socketOptions: [{ sendErrors: [new Error('send failed')] }],
500
+ });
501
+ const sendError = await captureError(sendHarness.discovery.start([
502
+ devicemanager.SSDP_SERVICE_TYPES.ROOT,
503
+ ]));
504
+ expect(sendError?.message).toEqual('send failed');
505
+ expect(sendHarness.discovery.isRunning).toEqual(false);
506
+ expect(sendHarness.sockets[0].closed).toEqual(true);
507
+ expect(sendHarness.clock.activeCount).toEqual(0);
508
+ });
509
+
510
+ tap.test('SSDP stop aborts pending descriptions and suppresses post-stop events', async () => {
511
+ let abortObserved = false;
512
+ const pendingFetch: typeof globalThis.fetch = async (_input, init) => {
513
+ return new Promise<Response>((_resolve, reject) => {
514
+ init?.signal?.addEventListener('abort', () => {
515
+ abortObserved = true;
516
+ reject(new Error('aborted'));
517
+ }, { once: true });
518
+ });
519
+ };
520
+ const harness = createHarness({ fetch: pendingFetch });
521
+ let found = 0;
522
+ let updated = 0;
523
+ harness.discovery.on('device:found', () => found++);
524
+ harness.discovery.on('device:updated', () => updated++);
525
+
526
+ await harness.discovery.start([devicemanager.SSDP_SERVICE_TYPES.ROOT]);
527
+ harness.sockets[0].emitMessage(createSsdpResponse());
528
+ await flushAsyncWork();
529
+ expect(harness.discovery.getDevices().length).toEqual(1);
530
+
531
+ await harness.discovery.stop();
532
+ expect(abortObserved).toEqual(true);
533
+ expect(found).toEqual(0);
534
+ expect(updated).toEqual(0);
535
+ expect(harness.discovery.getDevices().length).toEqual(0);
536
+ expect(harness.clock.activeCount).toEqual(0);
537
+
538
+ harness.sockets[0].emitMessage(createSsdpResponse());
539
+ await flushAsyncWork();
540
+ expect(found).toEqual(0);
541
+ expect(updated).toEqual(0);
542
+ });
543
+
544
+ tap.test('SSDP runtime transport errors stop and clean every fake handle', async () => {
545
+ const socketErrorHarness = createHarness();
546
+ const reportedErrors: Error[] = [];
547
+ let stopped = 0;
548
+ socketErrorHarness.discovery.on('error', (error: Error) => reportedErrors.push(error));
549
+ socketErrorHarness.discovery.on('stopped', () => stopped++);
550
+ await socketErrorHarness.discovery.start([devicemanager.SSDP_SERVICE_TYPES.ROOT]);
551
+ socketErrorHarness.sockets[0].emit('error', new Error('runtime socket failure'));
552
+ await socketErrorHarness.discovery.stop();
553
+
554
+ expect(reportedErrors.map((error) => error.message)).toEqual(['runtime socket failure']);
555
+ expect(stopped).toEqual(1);
556
+ expect(socketErrorHarness.discovery.isRunning).toEqual(false);
557
+ expect(socketErrorHarness.sockets[0].closed).toEqual(true);
558
+ expect(socketErrorHarness.clock.activeCount).toEqual(0);
559
+
560
+ const sendErrorHarness = createHarness({
561
+ socketOptions: [{ sendErrors: [null, new Error('custom send failure')] }],
562
+ });
563
+ await sendErrorHarness.discovery.start([devicemanager.SSDP_SERVICE_TYPES.ROOT]);
564
+ sendErrorHarness.discovery.search(devicemanager.SSDP_SERVICE_TYPES.MEDIA_SERVER);
565
+ await flushAsyncWork();
566
+ await sendErrorHarness.discovery.stop();
567
+ expect(sendErrorHarness.discovery.isRunning).toEqual(false);
568
+ expect(sendErrorHarness.sockets[0].closed).toEqual(true);
569
+ expect(sendErrorHarness.clock.activeCount).toEqual(0);
570
+ });
571
+
572
+ tap.test('mDNS stop attempts every cleanup and clears retained handles on failure', async () => {
573
+ const discovery = new devicemanager.MdnsDiscovery();
574
+ const internals = discovery as unknown as {
575
+ browsers: Array<{ stop: () => void }>;
576
+ bonjour: { destroy: (callback?: (error?: Error) => void) => void } | null;
577
+ isRunning: boolean;
578
+ };
579
+ const cleanupCalls: string[] = [];
580
+ let stopped = 0;
581
+ discovery.on('stopped', () => stopped++);
582
+
583
+ internals.browsers = [
584
+ {
585
+ stop: () => {
586
+ cleanupCalls.push('browser-1');
587
+ throw new Error('browser 1 stop failed');
588
+ },
589
+ },
590
+ {
591
+ stop: () => {
592
+ cleanupCalls.push('browser-2');
593
+ },
594
+ },
595
+ {
596
+ stop: () => {
597
+ cleanupCalls.push('browser-3');
598
+ throw new Error('browser 3 stop failed');
599
+ },
600
+ },
601
+ ];
602
+ internals.bonjour = {
603
+ destroy: () => {
604
+ cleanupCalls.push('bonjour');
605
+ throw new Error('bonjour destroy failed');
606
+ },
607
+ };
608
+ internals.isRunning = true;
609
+
610
+ const error = await captureError(discovery.stop());
611
+ expect(error).toBeInstanceOf(AggregateError);
612
+ expect((error as AggregateError).errors.map((item: unknown) =>
613
+ item instanceof Error ? item.message : String(item)
614
+ )).toEqual([
615
+ 'browser 1 stop failed',
616
+ 'browser 3 stop failed',
617
+ 'bonjour destroy failed',
618
+ ]);
619
+ expect(cleanupCalls).toEqual(['browser-1', 'browser-2', 'browser-3', 'bonjour']);
620
+ expect(discovery.running).toEqual(false);
621
+ expect(internals.browsers).toEqual([]);
622
+ expect(internals.bonjour).toEqual(null);
623
+ expect(stopped).toEqual(1);
624
+
625
+ await discovery.stop();
626
+ expect(cleanupCalls).toEqual(['browser-1', 'browser-2', 'browser-3', 'bonjour']);
627
+ });
628
+
629
+ tap.test('DeviceManager rolls back both discovery transports when either startup fails', async () => {
630
+ const manager = new devicemanager.DeviceManager({ autoDiscovery: false });
631
+ const internals = manager as unknown as {
632
+ mdnsDiscovery: {
633
+ start: () => Promise<void>;
634
+ stop: () => Promise<void>;
635
+ };
636
+ ssdpDiscovery: {
637
+ start: () => Promise<void>;
638
+ stop: () => Promise<void>;
639
+ };
640
+ };
641
+ let mdnsStarts = 0;
642
+ let mdnsStops = 0;
643
+ let ssdpStarts = 0;
644
+ let ssdpStops = 0;
645
+
646
+ internals.mdnsDiscovery.start = async () => {
647
+ mdnsStarts++;
648
+ };
649
+ internals.mdnsDiscovery.stop = async () => {
650
+ mdnsStops++;
651
+ };
652
+ internals.ssdpDiscovery.start = async () => {
653
+ ssdpStarts++;
654
+ throw new Error('SSDP startup failed');
655
+ };
656
+ internals.ssdpDiscovery.stop = async () => {
657
+ ssdpStops++;
658
+ };
659
+
660
+ const error = await captureError(manager.startDiscovery());
661
+ expect(error?.message).toEqual('SSDP startup failed');
662
+ expect({ mdnsStarts, mdnsStops, ssdpStarts, ssdpStops }).toEqual({
663
+ mdnsStarts: 1,
664
+ mdnsStops: 1,
665
+ ssdpStarts: 1,
666
+ ssdpStops: 1,
667
+ });
668
+ });
669
+
670
+ tap.test('DeviceManager reports incomplete rollback without retaining transport handles', async () => {
671
+ const manager = new devicemanager.DeviceManager({ autoDiscovery: false });
672
+ const internals = manager as unknown as {
673
+ mdnsDiscovery: {
674
+ start: () => Promise<void>;
675
+ stop: () => Promise<void>;
676
+ };
677
+ ssdpDiscovery: {
678
+ start: () => Promise<void>;
679
+ stop: () => Promise<void>;
680
+ };
681
+ };
682
+ const startupError = new Error('SSDP startup failed after allocation');
683
+ const cleanupError = new Error('mDNS cleanup failed');
684
+ let mdnsHandleActive = false;
685
+ let ssdpHandleActive = false;
686
+ let mdnsStops = 0;
687
+ let ssdpStops = 0;
688
+
689
+ internals.mdnsDiscovery.start = async () => {
690
+ mdnsHandleActive = true;
691
+ };
692
+ internals.mdnsDiscovery.stop = () => {
693
+ mdnsStops++;
694
+ mdnsHandleActive = false;
695
+ throw cleanupError;
696
+ };
697
+ internals.ssdpDiscovery.start = async () => {
698
+ ssdpHandleActive = true;
699
+ throw startupError;
700
+ };
701
+ internals.ssdpDiscovery.stop = async () => {
702
+ ssdpStops++;
703
+ ssdpHandleActive = false;
704
+ };
705
+
706
+ const error = await captureError(manager.startDiscovery());
707
+ expect(error).toBeInstanceOf(AggregateError);
708
+ const aggregateError = error as AggregateError;
709
+ expect(aggregateError.message).toContain('rollback cleanup was incomplete');
710
+ expect(aggregateError.cause === startupError).toEqual(true);
711
+ expect(aggregateError.errors[0] === startupError).toEqual(true);
712
+ expect(aggregateError.errors[1] === cleanupError).toEqual(true);
713
+ expect({ mdnsStops, ssdpStops }).toEqual({ mdnsStops: 1, ssdpStops: 1 });
714
+ expect({ mdnsHandleActive, ssdpHandleActive }).toEqual({
715
+ mdnsHandleActive: false,
716
+ ssdpHandleActive: false,
717
+ });
718
+ });
719
+
720
+ tap.test('DeviceManager awaits both stop paths when one rejects', async () => {
721
+ const manager = new devicemanager.DeviceManager({ autoDiscovery: false });
722
+ const activeResources = new Set(['mdns', 'ssdp']);
723
+ const mdnsError = new Error('mDNS stop failed');
724
+ let ssdpStopStarted = false;
725
+ let releaseSsdpStop!: () => void;
726
+ const internals = manager as unknown as {
727
+ mdnsDiscovery: {
728
+ readonly running: boolean;
729
+ stop: () => Promise<void>;
730
+ };
731
+ ssdpDiscovery: {
732
+ readonly isRunning: boolean;
733
+ stop: () => Promise<void>;
734
+ };
735
+ };
736
+
737
+ internals.mdnsDiscovery = {
738
+ get running(): boolean {
739
+ return activeResources.has('mdns');
740
+ },
741
+ stop: async () => {
742
+ activeResources.delete('mdns');
743
+ throw mdnsError;
744
+ },
745
+ };
746
+ internals.ssdpDiscovery = {
747
+ get isRunning(): boolean {
748
+ return activeResources.has('ssdp');
749
+ },
750
+ stop: async () => {
751
+ ssdpStopStarted = true;
752
+ await new Promise<void>((resolve) => {
753
+ releaseSsdpStop = resolve;
754
+ });
755
+ activeResources.delete('ssdp');
756
+ },
757
+ };
758
+
759
+ let stopSettled = false;
760
+ const errorPromise = captureError(manager.stopDiscovery());
761
+ void errorPromise.then(() => {
762
+ stopSettled = true;
763
+ });
764
+ await flushAsyncWork();
765
+
766
+ expect(ssdpStopStarted).toEqual(true);
767
+ expect(stopSettled).toEqual(false);
768
+ expect(manager.isDiscovering).toEqual(true);
769
+ expect(Array.from(activeResources)).toEqual(['ssdp']);
770
+
771
+ releaseSsdpStop();
772
+ const error = await errorPromise;
773
+ expect(error).toBeInstanceOf(AggregateError);
774
+ expect((error as AggregateError).errors).toEqual([mdnsError]);
775
+ expect((error as AggregateError).cause === mdnsError).toEqual(true);
776
+ expect(stopSettled).toEqual(true);
777
+ expect(manager.isDiscovering).toEqual(false);
778
+ expect(Array.from(activeResources)).toEqual([]);
779
+ });
780
+
781
+ tap.test('DeviceManager aggregates both stop failures after releasing all resources', async () => {
782
+ const manager = new devicemanager.DeviceManager({ autoDiscovery: false });
783
+ const activeResources = new Set(['mdns', 'ssdp']);
784
+ const mdnsError = new Error('mDNS stop failed');
785
+ const ssdpError = new Error('SSDP stop failed');
786
+ const stopCalls: string[] = [];
787
+ const internals = manager as unknown as {
788
+ mdnsDiscovery: {
789
+ readonly running: boolean;
790
+ stop: () => Promise<void>;
791
+ };
792
+ ssdpDiscovery: {
793
+ readonly isRunning: boolean;
794
+ stop: () => Promise<void>;
795
+ };
796
+ };
797
+
798
+ internals.mdnsDiscovery = {
799
+ get running(): boolean {
800
+ return activeResources.has('mdns');
801
+ },
802
+ stop: async () => {
803
+ stopCalls.push('mdns');
804
+ activeResources.delete('mdns');
805
+ throw mdnsError;
806
+ },
807
+ };
808
+ internals.ssdpDiscovery = {
809
+ get isRunning(): boolean {
810
+ return activeResources.has('ssdp');
811
+ },
812
+ stop: async () => {
813
+ stopCalls.push('ssdp');
814
+ activeResources.delete('ssdp');
815
+ throw ssdpError;
816
+ },
817
+ };
818
+
819
+ const error = await captureError(manager.stopDiscovery());
820
+ expect(error).toBeInstanceOf(AggregateError);
821
+ expect((error as AggregateError).errors).toEqual([mdnsError, ssdpError]);
822
+ expect(stopCalls).toEqual(['mdns', 'ssdp']);
823
+ expect(manager.isDiscovering).toEqual(false);
824
+ expect(Array.from(activeResources)).toEqual([]);
825
+ });
826
+
827
+ tap.test('SSDP cancels non-success description and SOAP response bodies', async () => {
828
+ let cancelledBodies = 0;
829
+ const harness = createHarness({
830
+ fetch: async () => new Response(new ReadableStream({
831
+ cancel: () => {
832
+ cancelledBodies++;
833
+ },
834
+ }), { status: 500 }),
835
+ });
836
+ let found = 0;
837
+ harness.discovery.on('device:found', () => found++);
838
+
839
+ await harness.discovery.start([devicemanager.SSDP_SERVICE_TYPES.ROOT]);
840
+ harness.sockets[0].emitMessage(createSsdpResponse());
841
+ await flushAsyncWork();
842
+ expect(found).toEqual(1);
843
+ expect(cancelledBodies).toEqual(1);
844
+
845
+ const soapError = await captureError(harness.discovery.soapRequest(
846
+ 'http://192.168.1.50/control',
847
+ 'urn:schemas-upnp-org:service:AVTransport:1',
848
+ 'Play'
849
+ ));
850
+ expect(soapError?.message).toEqual('SOAP request failed: 500');
851
+ expect(cancelledBodies).toEqual(2);
852
+ expect(harness.clock.activeCount).toEqual(1);
853
+ await harness.discovery.stop();
854
+ expect(harness.clock.activeCount).toEqual(0);
855
+ });
856
+
857
+ tap.test('SSDP SOAP and Chromecast public APIs remain available without removed dependencies', async () => {
858
+ let resolveSoapBody!: (body: string) => void;
859
+ const harness = createHarness({
860
+ fetch: async () => ({
861
+ ok: true,
862
+ status: 200,
863
+ text: async () => new Promise<string>((resolve) => {
864
+ resolveSoapBody = resolve;
865
+ }),
866
+ }) as Response,
867
+ });
868
+ const resultPromise = harness.discovery.soapRequest(
869
+ 'http://192.168.1.50/control',
870
+ 'urn:schemas-upnp-org:service:AVTransport:1',
871
+ 'Play',
872
+ { InstanceID: 0, Speed: 1 }
873
+ );
874
+ await flushAsyncWork();
875
+ expect(harness.clock.activeCount).toEqual(1);
876
+ resolveSoapBody('<result>ok</result>');
877
+ const result = await resultPromise;
878
+ expect(result).toEqual('<result>ok</result>');
879
+ expect(harness.fetchCalls[0].init?.method).toEqual('POST');
880
+ expect(String(harness.fetchCalls[0].init?.body)).toInclude('<InstanceID>0</InstanceID>');
881
+ expect(harness.clock.activeCount).toEqual(0);
882
+
883
+ const chromecastProtocol: devicemanager.TPlaybackProtocol = 'chromecast';
884
+ const chromecast = devicemanager.createSpeaker({
885
+ id: 'speaker:chromecast',
886
+ name: 'Chromecast',
887
+ address: '192.168.1.60',
888
+ port: 8009,
889
+ protocol: chromecastProtocol,
890
+ });
891
+ expect(devicemanager.SERVICE_TYPES.GOOGLECAST).toEqual('_googlecast._tcp');
892
+ expect(chromecast.selectFeature<devicemanager.PlaybackFeature>('playback').protocol).toEqual('chromecast');
893
+ expect(chromecast.hasFeature('volume')).toEqual(true);
894
+ expect(typeof devicemanager.DeviceManager.prototype.addSpeaker).toEqual('function');
895
+ expect(typeof devicemanager.NetworkScanner.prototype.scan).toEqual('function');
896
+ });
897
+
898
+ tap.test('Sonos SSDP and network responses classify as Sonos speakers', async () => {
899
+ const harness = createHarness({
900
+ fetch: async () => new Response(SONOS_DEVICE_DESCRIPTION, { status: 200 }),
901
+ });
902
+ const foundDevices: ISsdpDevice[] = [];
903
+ harness.discovery.on('device:found', (device: ISsdpDevice) => foundDevices.push(device));
904
+
905
+ await harness.discovery.start([devicemanager.SSDP_SERVICE_TYPES.SONOS_ZONE_PLAYER]);
906
+ harness.sockets[0].emitMessage(createSsdpResponse(
907
+ 'uuid:RINCON_00000000000101400::urn:schemas-upnp-org:device:ZonePlayer:1',
908
+ 'http://192.168.1.70:1400/xml/device_description.xml',
909
+ devicemanager.SSDP_SERVICE_TYPES.SONOS_ZONE_PLAYER
910
+ ));
911
+ await flushAsyncWork();
912
+
913
+ expect(foundDevices.length).toEqual(1);
914
+ expect(foundDevices[0].serviceType).toEqual(devicemanager.SSDP_SERVICE_TYPES.SONOS_ZONE_PLAYER);
915
+ expect(foundDevices[0].description?.friendlyName).toEqual('Kitchen');
916
+
917
+ const manager = new devicemanager.DeviceManager({ autoDiscovery: false });
918
+ const managerInternals = manager as unknown as {
919
+ handleSsdpDeviceFound: (device: ISsdpDevice) => void;
920
+ };
921
+ managerInternals.handleSsdpDeviceFound(foundDevices[0]);
922
+
923
+ const discoveredSpeaker = manager.selectDevice({ address: '192.168.1.70' });
924
+ expect(discoveredSpeaker.name).toEqual('Kitchen');
925
+ expect(
926
+ discoveredSpeaker.selectFeature<devicemanager.PlaybackFeature>('playback').protocol
927
+ ).toEqual('sonos');
928
+ expect(
929
+ discoveredSpeaker.selectFeature<devicemanager.VolumeFeature>('volume').protocol
930
+ ).toEqual('sonos');
931
+
932
+ const server = createServer((request, response) => {
933
+ if (request.url !== '/xml/device_description.xml') {
934
+ response.writeHead(404).end();
935
+ return;
936
+ }
937
+ response.writeHead(200, { 'content-type': 'application/xml' });
938
+ response.end(SONOS_DEVICE_DESCRIPTION);
939
+ });
940
+ await new Promise<void>((resolve, reject) => {
941
+ const onError = (error: Error): void => reject(error);
942
+ server.once('error', onError);
943
+ server.listen(0, '127.0.0.1', () => {
944
+ server.off('error', onError);
945
+ resolve();
946
+ });
947
+ });
948
+
949
+ try {
950
+ const address = server.address();
951
+ if (!address || typeof address === 'string') {
952
+ throw new Error('Expected the Sonos test server to use a TCP address.');
953
+ }
954
+ const scanner = new devicemanager.NetworkScanner() as unknown as {
955
+ probeSonos: (
956
+ ip: string,
957
+ port: number,
958
+ timeout: number
959
+ ) => Promise<{ type: string; protocol: string; port: number; name: string; model?: string } | null>;
960
+ };
961
+ const networkDevice = await scanner.probeSonos('127.0.0.1', address.port, 1000);
962
+
963
+ expect(networkDevice).toEqual({
964
+ type: 'speaker',
965
+ protocol: 'sonos',
966
+ port: address.port,
967
+ name: 'Kitchen',
968
+ model: 'Sonos One',
969
+ });
970
+ } finally {
971
+ await new Promise<void>((resolve, reject) => {
972
+ server.close((error) => error ? reject(error) : resolve());
973
+ });
974
+ await harness.discovery.stop();
975
+ }
976
+ });
977
+
978
+ export default tap.start();