@yousolution/node-red-contrib-you-tunnel-websocket 1.1.1 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,60 @@
1
+ /**
2
+ * KI-002 — tunnelIdHeaderName Default Mismatch (Regression Test)
3
+ *
4
+ * This test proves that the HTML editor default for tunnelIdHeaderName
5
+ * ('') does not match the constructor default ('x-tunnel-id').
6
+ *
7
+ * Expected behavior after fix:
8
+ * - HTML default is 'x-tunnel-id', matching the constructor and documentation.
9
+ *
10
+ * Current (defective) behavior:
11
+ * - HTML default is '', which is inconsistent.
12
+ */
13
+
14
+ const fs = require('fs');
15
+ const path = require('path');
16
+
17
+ const HTML_PATH = path.join(__dirname, '..', 'nodes', 'wsTunnel.html');
18
+ const JS_PATH = path.join(__dirname, '..', 'nodes', 'wsTunnel.js');
19
+
20
+ describe('KI-002 — tunnelIdHeaderName default consistency (regression)', () => {
21
+ let htmlContent;
22
+ let jsContent;
23
+
24
+ beforeAll(() => {
25
+ htmlContent = fs.readFileSync(HTML_PATH, 'utf8');
26
+ jsContent = fs.readFileSync(JS_PATH, 'utf8');
27
+ });
28
+
29
+ test('HTML wstunnel registration default for tunnelIdHeaderName is "x-tunnel-id"', () => {
30
+ const match = htmlContent.match(
31
+ /tunnelIdHeaderName:\s*\{\s*value:\s*['"]([^'"]*)['"]\s*\}/
32
+ );
33
+ expect(match).not.toBeNull();
34
+
35
+ // ASSERTION: HTML default must be 'x-tunnel-id'
36
+ // This FAILS with current code because HTML default is ''
37
+ expect(match[1]).toBe('x-tunnel-id');
38
+ });
39
+
40
+ test('constructor default for tunnelIdHeaderName is "x-tunnel-id"', () => {
41
+ const match = jsContent.match(
42
+ /tunnelIdHeaderName:\s*n\.tunnelIdHeaderName\s*\|\|\s*['"]([^'"]*)['"]/
43
+ );
44
+ expect(match).not.toBeNull();
45
+ expect(match[1]).toBe('x-tunnel-id');
46
+ });
47
+
48
+ test('HTML and constructor defaults for tunnelIdHeaderName are identical', () => {
49
+ const htmlMatch = htmlContent.match(
50
+ /tunnelIdHeaderName:\s*\{\s*value:\s*['"]([^'"]*)['"]\s*\}/
51
+ );
52
+ const jsMatch = jsContent.match(
53
+ /tunnelIdHeaderName:\s*n\.tunnelIdHeaderName\s*\|\|\s*['"]([^'"]*)['"]/
54
+ );
55
+
56
+ expect(htmlMatch).not.toBeNull();
57
+ expect(jsMatch).not.toBeNull();
58
+ expect(htmlMatch[1]).toBe(jsMatch[1]);
59
+ });
60
+ });
@@ -0,0 +1,180 @@
1
+ /**
2
+ * KI-003 — Leftover Debug Log Statement (Regression Test)
3
+ *
4
+ * This test proves that the close handler logs a [DEBUG] message at info level
5
+ * instead of using logger.debug() without the [DEBUG] prefix.
6
+ *
7
+ * Expected behavior after fix:
8
+ * - Close handler logs at debug level (not info)
9
+ * - Message does not contain [DEBUG] prefix
10
+ *
11
+ * Current (defective) behavior:
12
+ * - Close handler logs at info level with [DEBUG] prefix
13
+ */
14
+
15
+ const path = require('path');
16
+
17
+ const WS_TUNNEL_PATH = path.join(__dirname, '..', 'nodes', 'wsTunnel.js');
18
+
19
+ // Mock stopWebSocketServer
20
+ const mockStopWebSocketServer = jest.fn(() => Promise.resolve());
21
+
22
+ // Mock startWebSocketServer
23
+ const mockStartWebSocketServer = jest.fn((opts) =>
24
+ Promise.resolve({
25
+ [opts.port]: {
26
+ webSocketServer: {
27
+ on: jest.fn(),
28
+ clients: new Set(),
29
+ },
30
+ },
31
+ })
32
+ );
33
+
34
+ // Mock logger
35
+ const mockLogger = {
36
+ info: jest.fn(),
37
+ warn: jest.fn(),
38
+ error: jest.fn(),
39
+ debug: jest.fn(),
40
+ trace: jest.fn(),
41
+ };
42
+
43
+ // Mock setLogContext and setLogLevel
44
+ const mockSetLogContext = jest.fn();
45
+ const mockSetLogLevel = jest.fn();
46
+
47
+ // Mock RED object
48
+ function createMockRED() {
49
+ const registeredTypes = {};
50
+ return {
51
+ nodes: {
52
+ createNode: jest.fn((node, config) => {
53
+ node.status = jest.fn();
54
+ node.on = jest.fn((event, handler) => {
55
+ node._handlers = node._handlers || {};
56
+ node._handlers[event] = handler;
57
+ });
58
+ }),
59
+ registerType: jest.fn((type, constructor) => {
60
+ registeredTypes[type] = constructor;
61
+ }),
62
+ getNode: jest.fn(() => ({
63
+ options: {
64
+ name: 'test-tunnel',
65
+ host: 'localhost',
66
+ port: 1880,
67
+ path: '',
68
+ tunnelIdHeaderName: 'x-tunnel-id',
69
+ logLevel: 'info',
70
+ },
71
+ })),
72
+ },
73
+ _registeredTypes: registeredTypes,
74
+ };
75
+ }
76
+
77
+ function createNode(RED) {
78
+ const WSTunnelServer = RED._registeredTypes['wstunnel server'];
79
+ const node = {};
80
+ WSTunnelServer.call(node, { wstunnel: 'test-config-id', name: 'test-server' });
81
+ return node;
82
+ }
83
+
84
+ describe('KI-003 — close handler debug log level (regression)', () => {
85
+ beforeAll(() => {
86
+ const serverModule = require('@remotelinker/reverse-ws-tunnel/server');
87
+ serverModule.stopWebSocketServer = mockStopWebSocketServer;
88
+ serverModule.startWebSocketServer = mockStartWebSocketServer;
89
+
90
+ const utilsModule = require('@remotelinker/reverse-ws-tunnel/utils');
91
+ utilsModule.logger = mockLogger;
92
+ utilsModule.setLogContext = mockSetLogContext;
93
+ utilsModule.setLogLevel = mockSetLogLevel;
94
+ });
95
+
96
+ beforeEach(() => {
97
+ mockStopWebSocketServer.mockClear();
98
+ mockStartWebSocketServer.mockClear();
99
+ mockLogger.info.mockClear();
100
+ mockLogger.debug.mockClear();
101
+ mockLogger.warn.mockClear();
102
+ mockLogger.error.mockClear();
103
+ mockSetLogContext.mockClear();
104
+ mockSetLogLevel.mockClear();
105
+ });
106
+
107
+ test('close handler does NOT log [DEBUG] at info level', async () => {
108
+ const RED = createMockRED();
109
+ const tunnelModule = require(WS_TUNNEL_PATH);
110
+ tunnelModule(RED);
111
+
112
+ // Both mocks are pre-resolved; microtask flush is sufficient
113
+ await Promise.resolve();
114
+
115
+ const node = createNode(RED);
116
+ const done = jest.fn();
117
+
118
+ mockLogger.info.mockClear();
119
+
120
+ node._handlers.close.call(node, false, done);
121
+
122
+ // Close handler logs synchronously; microtask flush ensures done() path is reached
123
+ await Promise.resolve();
124
+
125
+ // ASSERTION: info must NOT be called with [DEBUG] prefix
126
+ // This FAILS with current code because line 117 uses logger.info('[DEBUG]...')
127
+ const infoCalls = mockLogger.info.mock.calls.map((c) => c[0]);
128
+ expect(infoCalls.some((msg) => msg.includes('[DEBUG]'))).toBe(false);
129
+ });
130
+
131
+ test('close handler logs close message at debug level', async () => {
132
+ const RED = createMockRED();
133
+ const tunnelModule = require(WS_TUNNEL_PATH);
134
+ tunnelModule(RED);
135
+
136
+ // Both mocks are pre-resolved; microtask flush is sufficient
137
+ await Promise.resolve();
138
+
139
+ const node = createNode(RED);
140
+ const done = jest.fn();
141
+
142
+ mockLogger.debug.mockClear();
143
+
144
+ node._handlers.close.call(node, false, done);
145
+
146
+ // Close handler logs synchronously; microtask flush ensures done() path is reached
147
+ await Promise.resolve();
148
+
149
+ // ASSERTION: debug must be called with close message
150
+ // This FAILS with current code because the message is at info level, not debug
151
+ const debugCalls = mockLogger.debug.mock.calls.map((c) => c[0]);
152
+ expect(debugCalls.some((msg) => msg.includes('Close called for node'))).toBe(true);
153
+ });
154
+
155
+ test('close message does not contain [DEBUG] prefix', async () => {
156
+ const RED = createMockRED();
157
+ const tunnelModule = require(WS_TUNNEL_PATH);
158
+ tunnelModule(RED);
159
+
160
+ // Both mocks are pre-resolved; microtask flush is sufficient
161
+ await Promise.resolve();
162
+
163
+ const node = createNode(RED);
164
+ const done = jest.fn();
165
+
166
+ mockLogger.debug.mockClear();
167
+
168
+ node._handlers.close.call(node, false, done);
169
+
170
+ // Close handler logs synchronously; microtask flush ensures done() path is reached
171
+ await Promise.resolve();
172
+
173
+ // ASSERTION: debug message must not contain [DEBUG] prefix
174
+ const debugCalls = mockLogger.debug.mock.calls.map((c) => c[0]);
175
+ const closeMsg = debugCalls.find((msg) => msg.includes('Close called for node'));
176
+ if (closeMsg) {
177
+ expect(closeMsg).not.toContain('[DEBUG]');
178
+ }
179
+ });
180
+ });
@@ -0,0 +1,144 @@
1
+ /**
2
+ * KI-004 — console.error in Production Code (Regression Test)
3
+ *
4
+ * This test proves that the stop-failure error path uses console.error()
5
+ * instead of logger.error(), bypassing log level filtering and context.
6
+ *
7
+ * Expected behavior after fix:
8
+ * - Stop failure is logged via logger.error(), not console.error()
9
+ *
10
+ * Current (defective) behavior:
11
+ * - Stop failure is logged via console.error()
12
+ */
13
+
14
+ const path = require('path');
15
+
16
+ const WS_TUNNEL_PATH = path.join(__dirname, '..', 'nodes', 'wsTunnel.js');
17
+
18
+ // Mock state
19
+ let mockStopResolved = true;
20
+
21
+ // Mock stopWebSocketServer
22
+ const mockStopWebSocketServer = jest.fn((port) => {
23
+ return new Promise((resolve, reject) => {
24
+ setImmediate(() => {
25
+ if (mockStopResolved) {
26
+ resolve();
27
+ } else {
28
+ reject(new Error('Stop failed'));
29
+ }
30
+ });
31
+ });
32
+ });
33
+
34
+ // Mock startWebSocketServer
35
+ const mockStartWebSocketServer = jest.fn((opts) =>
36
+ Promise.resolve({
37
+ [opts.port]: {
38
+ webSocketServer: {
39
+ on: jest.fn(),
40
+ clients: new Set(),
41
+ },
42
+ },
43
+ })
44
+ );
45
+
46
+ // Mock logger
47
+ const mockLogger = {
48
+ info: jest.fn(),
49
+ warn: jest.fn(),
50
+ error: jest.fn(),
51
+ debug: jest.fn(),
52
+ trace: jest.fn(),
53
+ };
54
+
55
+ // Mock setLogContext and setLogLevel
56
+ const mockSetLogContext = jest.fn();
57
+ const mockSetLogLevel = jest.fn();
58
+
59
+ // Mock RED object
60
+ function createMockRED() {
61
+ const registeredTypes = {};
62
+ return {
63
+ nodes: {
64
+ createNode: jest.fn((node, config) => {
65
+ node.status = jest.fn();
66
+ node.on = jest.fn((event, handler) => {
67
+ node._handlers = node._handlers || {};
68
+ node._handlers[event] = handler;
69
+ });
70
+ }),
71
+ registerType: jest.fn((type, constructor) => {
72
+ registeredTypes[type] = constructor;
73
+ }),
74
+ getNode: jest.fn(() => ({
75
+ options: {
76
+ name: 'test-tunnel',
77
+ host: 'localhost',
78
+ port: 1880,
79
+ path: '',
80
+ tunnelIdHeaderName: 'x-tunnel-id',
81
+ logLevel: 'info',
82
+ },
83
+ })),
84
+ },
85
+ _registeredTypes: registeredTypes,
86
+ };
87
+ }
88
+
89
+ describe('KI-004 — error logging mechanism (regression)', () => {
90
+ beforeAll(() => {
91
+ const serverModule = require('@remotelinker/reverse-ws-tunnel/server');
92
+ serverModule.stopWebSocketServer = mockStopWebSocketServer;
93
+ serverModule.startWebSocketServer = mockStartWebSocketServer;
94
+
95
+ const utilsModule = require('@remotelinker/reverse-ws-tunnel/utils');
96
+ utilsModule.logger = mockLogger;
97
+ utilsModule.setLogContext = mockSetLogContext;
98
+ utilsModule.setLogLevel = mockSetLogLevel;
99
+ });
100
+
101
+ beforeEach(() => {
102
+ mockStopResolved = true;
103
+ mockStopWebSocketServer.mockClear();
104
+ mockStartWebSocketServer.mockClear();
105
+ mockLogger.info.mockClear();
106
+ mockLogger.debug.mockClear();
107
+ mockLogger.error.mockClear();
108
+ mockLogger.warn.mockClear();
109
+ mockSetLogContext.mockClear();
110
+ mockSetLogLevel.mockClear();
111
+ });
112
+
113
+ test('stop failure is logged via logger.error, not console.error', async () => {
114
+ const RED = createMockRED();
115
+ const tunnelModule = require(WS_TUNNEL_PATH);
116
+ tunnelModule(RED);
117
+
118
+ const WSTunnelServer = RED._registeredTypes['wstunnel server'];
119
+ const node = {};
120
+ WSTunnelServer.call(node, { wstunnel: 'test-config-id', name: 'test-server' });
121
+
122
+ // Wait for constructor to succeed and set instances[id].state
123
+ // mockStopWebSocketServer resolves via setImmediate
124
+ await new Promise((resolve) => setImmediate(resolve));
125
+
126
+ // Now make stop fail
127
+ mockStopResolved = false;
128
+
129
+ const done = jest.fn();
130
+ mockLogger.error.mockClear();
131
+
132
+ node._handlers.close.call(node, false, done);
133
+
134
+ // Wait for close handler promise chain
135
+ // mockStopWebSocketServer rejects via setImmediate
136
+ await new Promise((resolve) => setImmediate(resolve));
137
+
138
+ // ASSERTION: logger.error must be called with the error message
139
+ // This FAILS with current code because console.error is used instead
140
+ expect(mockLogger.error).toHaveBeenCalled();
141
+ const errorCalls = mockLogger.error.mock.calls.map((c) => c[0]);
142
+ expect(errorCalls.some((msg) => msg.includes('Error stopping server'))).toBe(true);
143
+ });
144
+ });
@@ -0,0 +1,227 @@
1
+ /**
2
+ * M-3 — Concurrent Deployment on Same Port (Regression Test)
3
+ *
4
+ * Documents the interleaving when two wstunnel server nodes target the same
5
+ * port. The second node fails with EADDRINUSE because the library's
6
+ * startWebSocketServer (synchronous) creates a new WebSocket.Server which
7
+ * throws if the port is already bound.
8
+ *
9
+ * Observable behavior:
10
+ * 1. Node A succeeds; Node A stores state in instances[nodeA].
11
+ * 2. Node B fails; Node B has no state (instances[nodeB] stays empty).
12
+ * 3. Node A close → calls stopWebSocketServer → cleans up.
13
+ * 4. Node B close → no-op (no state to clean up).
14
+ *
15
+ * RWT-001 is NOT violated: the second node never starts. The library
16
+ * throws EADDRINUSE, which is caught by .catch().
17
+ */
18
+
19
+ const path = require('path');
20
+
21
+ const WS_TUNNEL_PATH = path.join(__dirname, '..', 'nodes', 'wsTunnel.js');
22
+
23
+ // Track which ports have been "started" — second call on same port throws
24
+ let startedPorts = new Set();
25
+
26
+ // Mock stopWebSocketServer — resolves via setImmediate (no-op, no state check)
27
+ const mockStopWebSocketServer = jest.fn(() =>
28
+ new Promise((resolve) => setImmediate(resolve))
29
+ );
30
+
31
+ // Mock startWebSocketServer — succeeds first call, throws EADDRINUSE for duplicate port
32
+ const mockStartWebSocketServer = jest.fn((opts) => {
33
+ const portKey = String(opts.port);
34
+ if (startedPorts.has(portKey)) {
35
+ throw new Error(`EADDRINUSE: port ${opts.port} already in use`);
36
+ }
37
+ startedPorts.add(portKey);
38
+ return {
39
+ [opts.port]: {
40
+ webSocketServer: {
41
+ on: jest.fn(),
42
+ clients: new Set(),
43
+ },
44
+ },
45
+ };
46
+ });
47
+
48
+ // Mock logger
49
+ const mockLogger = {
50
+ info: jest.fn(),
51
+ warn: jest.fn(),
52
+ error: jest.fn(),
53
+ debug: jest.fn(),
54
+ trace: jest.fn(),
55
+ };
56
+
57
+ const mockSetLogContext = jest.fn();
58
+ const mockSetLogLevel = jest.fn();
59
+
60
+ function createMockRED() {
61
+ const registeredTypes = {};
62
+ return {
63
+ nodes: {
64
+ createNode: jest.fn((node, config) => {
65
+ if (!node.id) node.id = 'node-' + Math.random().toString(36).slice(2, 9);
66
+ node.status = jest.fn();
67
+ node.on = jest.fn((event, handler) => {
68
+ node._handlers = node._handlers || {};
69
+ node._handlers[event] = handler;
70
+ });
71
+ }),
72
+ registerType: jest.fn((type, constructor) => {
73
+ registeredTypes[type] = constructor;
74
+ }),
75
+ getNode: jest.fn(() => ({
76
+ options: {
77
+ name: 'test-tunnel',
78
+ host: 'localhost',
79
+ port: 1880,
80
+ path: '',
81
+ tunnelIdHeaderName: 'x-tunnel-id',
82
+ logLevel: 'info',
83
+ },
84
+ })),
85
+ },
86
+ _registeredTypes: registeredTypes,
87
+ };
88
+ }
89
+
90
+ describe('M-3 — Concurrent deployment on same port', () => {
91
+ beforeAll(() => {
92
+ const serverModule = require('@remotelinker/reverse-ws-tunnel/server');
93
+ serverModule.stopWebSocketServer = mockStopWebSocketServer;
94
+ serverModule.startWebSocketServer = mockStartWebSocketServer;
95
+
96
+ const utilsModule = require('@remotelinker/reverse-ws-tunnel/utils');
97
+ utilsModule.logger = mockLogger;
98
+ utilsModule.setLogContext = mockSetLogContext;
99
+ utilsModule.setLogLevel = mockSetLogLevel;
100
+ });
101
+
102
+ beforeEach(() => {
103
+ startedPorts = new Set();
104
+ mockStopWebSocketServer.mockClear();
105
+ mockStartWebSocketServer.mockClear();
106
+ mockLogger.info.mockClear();
107
+ mockLogger.error.mockClear();
108
+ mockLogger.debug.mockClear();
109
+ mockLogger.warn.mockClear();
110
+ mockSetLogContext.mockClear();
111
+ mockSetLogLevel.mockClear();
112
+ });
113
+
114
+ test('second node on same port fails with EADDRINUSE', async () => {
115
+ const RED = createMockRED();
116
+ const tunnelModule = require(WS_TUNNEL_PATH);
117
+ tunnelModule(RED);
118
+
119
+ const WSTunnelServer = RED._registeredTypes['wstunnel server'];
120
+
121
+ // Node A — succeeds
122
+ const nodeA = { id: 'nodeA' };
123
+ WSTunnelServer.call(nodeA, { wstunnel: 'test-config-id', name: 'server-A' });
124
+ await new Promise((resolve) => setImmediate(resolve));
125
+
126
+ // Clear logs after Node A construction
127
+ mockLogger.error.mockClear();
128
+
129
+ // Node B — fails (same port)
130
+ const nodeB = { id: 'nodeB' };
131
+ WSTunnelServer.call(nodeB, { wstunnel: 'test-config-id', name: 'server-B' });
132
+ await new Promise((resolve) => setImmediate(resolve));
133
+
134
+ // Node A started successfully — no error logged after its construction
135
+ const startErrors = mockLogger.error.mock.calls.filter((c) =>
136
+ c[0].includes('Error starting WebSocketServer')
137
+ );
138
+ expect(startErrors.length).toBe(1);
139
+ expect(startErrors[0][0]).toContain('1880');
140
+ });
141
+
142
+ test('node B close handler is a no-op after failed start', async () => {
143
+ const RED = createMockRED();
144
+ const tunnelModule = require(WS_TUNNEL_PATH);
145
+ tunnelModule(RED);
146
+
147
+ const WSTunnelServer = RED._registeredTypes['wstunnel server'];
148
+
149
+ const nodeA = { id: 'nodeA' };
150
+ WSTunnelServer.call(nodeA, { wstunnel: 'test-config-id', name: 'server-A' });
151
+ await new Promise((resolve) => setImmediate(resolve));
152
+
153
+ const nodeB = { id: 'nodeB' };
154
+ WSTunnelServer.call(nodeB, { wstunnel: 'test-config-id', name: 'server-B' });
155
+ await new Promise((resolve) => setImmediate(resolve));
156
+
157
+ const stopCountBefore = mockStopWebSocketServer.mock.calls.length;
158
+
159
+ // Close Node B — should be a no-op (no state stored)
160
+ const doneB = jest.fn();
161
+ nodeB._handlers.close.call(nodeB, false, doneB);
162
+ await Promise.resolve();
163
+
164
+ expect(doneB).toHaveBeenCalledTimes(1);
165
+ expect(mockStopWebSocketServer.mock.calls.length).toBe(stopCountBefore);
166
+ });
167
+
168
+ test('node A close calls stopWebSocketServer and cleans up', async () => {
169
+ const RED = createMockRED();
170
+ const tunnelModule = require(WS_TUNNEL_PATH);
171
+ tunnelModule(RED);
172
+
173
+ const WSTunnelServer = RED._registeredTypes['wstunnel server'];
174
+
175
+ const nodeA = { id: 'nodeA' };
176
+ WSTunnelServer.call(nodeA, { wstunnel: 'test-config-id', name: 'server-A' });
177
+ await new Promise((resolve) => setImmediate(resolve));
178
+
179
+ const nodeB = { id: 'nodeB' };
180
+ WSTunnelServer.call(nodeB, { wstunnel: 'test-config-id', name: 'server-B' });
181
+ await new Promise((resolve) => setImmediate(resolve));
182
+
183
+ mockStopWebSocketServer.mockClear();
184
+
185
+ // Close Node A — should call stopWebSocketServer
186
+ const doneA = jest.fn();
187
+ nodeA._handlers.close.call(nodeA, false, doneA);
188
+ await new Promise((resolve) => setImmediate(resolve));
189
+
190
+ expect(doneA).toHaveBeenCalledTimes(1);
191
+ expect(mockStopWebSocketServer).toHaveBeenCalledWith(1880);
192
+ });
193
+
194
+ test('total stop calls = 2 from construction + 1 from node A close', async () => {
195
+ const RED = createMockRED();
196
+ const tunnelModule = require(WS_TUNNEL_PATH);
197
+ tunnelModule(RED);
198
+
199
+ const WSTunnelServer = RED._registeredTypes['wstunnel server'];
200
+
201
+ const nodeA = { id: 'nodeA' };
202
+ WSTunnelServer.call(nodeA, { wstunnel: 'test-config-id', name: 'server-A' });
203
+ await new Promise((resolve) => setImmediate(resolve));
204
+
205
+ const nodeB = { id: 'nodeB' };
206
+ WSTunnelServer.call(nodeB, { wstunnel: 'test-config-id', name: 'server-B' });
207
+ await new Promise((resolve) => setImmediate(resolve));
208
+
209
+ // 2 stop calls from construction (both no-ops)
210
+ const constructionStopCalls = mockStopWebSocketServer.mock.calls.length;
211
+ expect(constructionStopCalls).toBe(2);
212
+
213
+ // Close both nodes
214
+ const doneA = jest.fn();
215
+ const doneB = jest.fn();
216
+ nodeA._handlers.close.call(nodeA, false, doneA);
217
+ nodeB._handlers.close.call(nodeB, false, doneB);
218
+
219
+ await new Promise((resolve) => setImmediate(resolve));
220
+ await Promise.resolve();
221
+
222
+ // Total: 2 (construction) + 1 (Node A close) = 3
223
+ expect(mockStopWebSocketServer.mock.calls.length).toBe(constructionStopCalls + 1);
224
+ expect(doneA).toHaveBeenCalledTimes(1);
225
+ expect(doneB).toHaveBeenCalledTimes(1);
226
+ });
227
+ });