@yousolution/node-red-contrib-you-tunnel-websocket 1.1.2 → 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,212 @@
1
+ /**
2
+ * Start Failure Path — startWebSocketServer rejection during construction
3
+ *
4
+ * This test proves that when startWebSocketServer() rejects during
5
+ * WSTunnelServer construction, the error is caught and logged via
6
+ * logger.error(), not lost as an unhandled rejection.
7
+ *
8
+ * Production code (wsTunnel.js lines 75-82):
9
+ * .catch((err) => {
10
+ * logger.error(`Error starting WebSocketServer on port ${port}:`, err);
11
+ * node.status({ fill: 'red', shape: 'dot', text: `error: ${err.message}` });
12
+ * });
13
+ */
14
+
15
+ const path = require('path');
16
+
17
+ const WS_TUNNEL_PATH = path.join(__dirname, '..', 'nodes', 'wsTunnel.js');
18
+
19
+ // Mock stopWebSocketServer — resolves successfully
20
+ const mockStopWebSocketServer = jest.fn(() => Promise.resolve());
21
+
22
+ // Mock startWebSocketServer — rejects with a controlled error
23
+ const START_ERROR = new Error('EADDRINUSE: port 1880 already in use');
24
+ let mockStartRejects = false;
25
+
26
+ const mockStartWebSocketServer = jest.fn((opts) => {
27
+ if (mockStartRejects) {
28
+ return Promise.reject(START_ERROR);
29
+ }
30
+ return Promise.resolve({
31
+ [opts.port]: {
32
+ webSocketServer: {
33
+ on: jest.fn(),
34
+ clients: new Set(),
35
+ },
36
+ },
37
+ });
38
+ });
39
+
40
+ // Mock logger
41
+ const mockLogger = {
42
+ info: jest.fn(),
43
+ warn: jest.fn(),
44
+ error: jest.fn(),
45
+ debug: jest.fn(),
46
+ trace: jest.fn(),
47
+ };
48
+
49
+ // Mock setLogContext and setLogLevel
50
+ const mockSetLogContext = jest.fn();
51
+ const mockSetLogLevel = jest.fn();
52
+
53
+ // Mock RED object
54
+ function createMockRED() {
55
+ const registeredTypes = {};
56
+ return {
57
+ nodes: {
58
+ createNode: jest.fn((node, config) => {
59
+ node.status = jest.fn();
60
+ node.on = jest.fn((event, handler) => {
61
+ node._handlers = node._handlers || {};
62
+ node._handlers[event] = handler;
63
+ });
64
+ }),
65
+ registerType: jest.fn((type, constructor) => {
66
+ registeredTypes[type] = constructor;
67
+ }),
68
+ getNode: jest.fn(() => ({
69
+ options: {
70
+ name: 'test-tunnel',
71
+ host: 'localhost',
72
+ port: 1880,
73
+ path: '',
74
+ tunnelIdHeaderName: 'x-tunnel-id',
75
+ logLevel: 'info',
76
+ },
77
+ })),
78
+ },
79
+ _registeredTypes: registeredTypes,
80
+ };
81
+ }
82
+
83
+ describe('Start failure path — startWebSocketServer rejection', () => {
84
+ beforeAll(() => {
85
+ const serverModule = require('@remotelinker/reverse-ws-tunnel/server');
86
+ serverModule.stopWebSocketServer = mockStopWebSocketServer;
87
+ serverModule.startWebSocketServer = mockStartWebSocketServer;
88
+
89
+ const utilsModule = require('@remotelinker/reverse-ws-tunnel/utils');
90
+ utilsModule.logger = mockLogger;
91
+ utilsModule.setLogContext = mockSetLogContext;
92
+ utilsModule.setLogLevel = mockSetLogLevel;
93
+ });
94
+
95
+ beforeEach(() => {
96
+ mockStartRejects = false;
97
+ mockStopWebSocketServer.mockClear();
98
+ mockStartWebSocketServer.mockClear();
99
+ mockLogger.info.mockClear();
100
+ mockLogger.error.mockClear();
101
+ mockLogger.debug.mockClear();
102
+ mockLogger.warn.mockClear();
103
+ mockSetLogContext.mockClear();
104
+ mockSetLogLevel.mockClear();
105
+ });
106
+
107
+ test('startWebSocketServer rejection is caught and logged via logger.error', async () => {
108
+ mockStartRejects = true;
109
+
110
+ const RED = createMockRED();
111
+ const tunnelModule = require(WS_TUNNEL_PATH);
112
+ tunnelModule(RED);
113
+
114
+ const WSTunnelServer = RED._registeredTypes['wstunnel server'];
115
+ const node = {};
116
+ WSTunnelServer.call(node, { wstunnel: 'test-config-id', name: 'test-server' });
117
+
118
+ // Flush stopWebSocketServer().then(startWebSocketServer().catch())
119
+ await new Promise((resolve) => setImmediate(resolve));
120
+ await new Promise((resolve) => setImmediate(resolve));
121
+
122
+ // ASSERTION: logger.error must be called with the start failure message
123
+ expect(mockLogger.error).toHaveBeenCalled();
124
+ const errorCalls = mockLogger.error.mock.calls.map((c) => c[0]);
125
+ expect(errorCalls.some((msg) => msg.includes('Error starting WebSocketServer'))).toBe(true);
126
+ });
127
+
128
+ test('the original error is passed to logger.error as the second argument', async () => {
129
+ mockStartRejects = true;
130
+
131
+ const RED = createMockRED();
132
+ const tunnelModule = require(WS_TUNNEL_PATH);
133
+ tunnelModule(RED);
134
+
135
+ const WSTunnelServer = RED._registeredTypes['wstunnel server'];
136
+ const node = {};
137
+ WSTunnelServer.call(node, { wstunnel: 'test-config-id', name: 'test-server' });
138
+
139
+ await new Promise((resolve) => setImmediate(resolve));
140
+ await new Promise((resolve) => setImmediate(resolve));
141
+
142
+ // ASSERTION: The error object itself is passed (not just the message string)
143
+ const errorCalls = mockLogger.error.mock.calls.filter((c) =>
144
+ c[0].includes('Error starting WebSocketServer')
145
+ );
146
+ expect(errorCalls.length).toBe(1);
147
+ expect(errorCalls[0][1]).toBe(START_ERROR);
148
+ });
149
+
150
+ test('node.status is set to error state with the error message', async () => {
151
+ mockStartRejects = true;
152
+
153
+ const RED = createMockRED();
154
+ const tunnelModule = require(WS_TUNNEL_PATH);
155
+ tunnelModule(RED);
156
+
157
+ const WSTunnelServer = RED._registeredTypes['wstunnel server'];
158
+ const node = {};
159
+ WSTunnelServer.call(node, { wstunnel: 'test-config-id', name: 'test-server' });
160
+
161
+ await new Promise((resolve) => setImmediate(resolve));
162
+ await new Promise((resolve) => setImmediate(resolve));
163
+
164
+ // ASSERTION: node.status was called with error state
165
+ expect(node.status).toHaveBeenCalledWith({
166
+ fill: 'red',
167
+ shape: 'dot',
168
+ text: `error: ${START_ERROR.message}`,
169
+ });
170
+ });
171
+
172
+ test('stopWebSocketServer still runs before start failure is handled', async () => {
173
+ mockStartRejects = true;
174
+
175
+ const RED = createMockRED();
176
+ const tunnelModule = require(WS_TUNNEL_PATH);
177
+ tunnelModule(RED);
178
+
179
+ const WSTunnelServer = RED._registeredTypes['wstunnel server'];
180
+ const node = {};
181
+ WSTunnelServer.call(node, { wstunnel: 'test-config-id', name: 'test-server' });
182
+
183
+ await new Promise((resolve) => setImmediate(resolve));
184
+ await new Promise((resolve) => setImmediate(resolve));
185
+
186
+ // ASSERTION: stopWebSocketServer was called (cleanup runs before start)
187
+ expect(mockStopWebSocketServer).toHaveBeenCalled();
188
+ // ASSERTION: startWebSocketServer was called (it rejected)
189
+ expect(mockStartWebSocketServer).toHaveBeenCalled();
190
+ });
191
+
192
+ test('successful start does not log error', async () => {
193
+ mockStartRejects = false;
194
+
195
+ const RED = createMockRED();
196
+ const tunnelModule = require(WS_TUNNEL_PATH);
197
+ tunnelModule(RED);
198
+
199
+ const WSTunnelServer = RED._registeredTypes['wstunnel server'];
200
+ const node = {};
201
+ WSTunnelServer.call(node, { wstunnel: 'test-config-id', name: 'test-server' });
202
+
203
+ await new Promise((resolve) => setImmediate(resolve));
204
+ await new Promise((resolve) => setImmediate(resolve));
205
+
206
+ // ASSERTION: logger.error must NOT be called for successful start
207
+ const errorCalls = mockLogger.error.mock.calls.filter((c) =>
208
+ c[0].includes('Error starting WebSocketServer')
209
+ );
210
+ expect(errorCalls.length).toBe(0);
211
+ });
212
+ });
@@ -0,0 +1,203 @@
1
+ /**
2
+ * wstunnel command — Regression Test
3
+ *
4
+ * Tests that the wstunnel command node correctly sends commands to tunnel
5
+ * clients via sendCommand(), routes success to output 0, failure to output 1,
6
+ * and validates input parameters.
7
+ */
8
+
9
+ const path = require('path');
10
+
11
+ const WS_TUNNEL_PATH = path.join(__dirname, '..', 'nodes', 'wsTunnel.js');
12
+
13
+ const PORT = 4443;
14
+
15
+ const mockLogger = {
16
+ info: jest.fn(),
17
+ warn: jest.fn(),
18
+ error: jest.fn(),
19
+ debug: jest.fn(),
20
+ trace: jest.fn(),
21
+ };
22
+ const mockSetLogContext = jest.fn();
23
+ const mockSetLogLevel = jest.fn();
24
+
25
+ function createMockRED() {
26
+ const registeredTypes = {};
27
+ return {
28
+ nodes: {
29
+ createNode: jest.fn((node) => {
30
+ if (!node.id) node.id = 'node-' + Math.random().toString(36).slice(2, 9);
31
+ node.status = jest.fn();
32
+ node.send = jest.fn();
33
+ node.error = jest.fn();
34
+ node.on = jest.fn((event, handler) => {
35
+ node._handlers = node._handlers || {};
36
+ node._handlers[event] = handler;
37
+ });
38
+ }),
39
+ registerType: jest.fn((type, constructor) => {
40
+ registeredTypes[type] = constructor;
41
+ }),
42
+ getNode: jest.fn(() => ({
43
+ options: {
44
+ name: 'test-tunnel',
45
+ host: 'localhost',
46
+ port: PORT,
47
+ path: '',
48
+ tunnelIdHeaderName: 'x-tunnel-id',
49
+ logLevel: 'info',
50
+ },
51
+ })),
52
+ },
53
+ _registeredTypes: registeredTypes,
54
+ };
55
+ }
56
+
57
+ describe('wstunnel command', () => {
58
+ let mockSendCommand;
59
+
60
+ beforeAll(() => {
61
+ mockSendCommand = jest.fn();
62
+
63
+ const serverModule = require('@remotelinker/reverse-ws-tunnel/server');
64
+ serverModule.sendCommand = mockSendCommand;
65
+
66
+ const utilsModule = require('@remotelinker/reverse-ws-tunnel/utils');
67
+ utilsModule.logger = mockLogger;
68
+ utilsModule.setLogContext = mockSetLogContext;
69
+ utilsModule.setLogLevel = mockSetLogLevel;
70
+ });
71
+
72
+ beforeEach(() => {
73
+ mockSendCommand.mockReset();
74
+ mockSendCommand.mockReturnValue(true);
75
+ mockLogger.error.mockClear();
76
+ });
77
+
78
+ test('sends command to output 0 when sendCommand returns true', () => {
79
+ const RED = createMockRED();
80
+ const tunnelModule = require(WS_TUNNEL_PATH);
81
+ tunnelModule(RED);
82
+
83
+ const WSTunnelCommand = RED._registeredTypes['wstunnel command'];
84
+ expect(WSTunnelCommand).toBeDefined();
85
+
86
+ const node = {};
87
+ WSTunnelCommand.call(node, { wstunnel: 'test-config-id', name: 'cmd' });
88
+
89
+ expect(node.on).toHaveBeenCalledWith('input', expect.any(Function));
90
+
91
+ const inputHandler = node._handlers['input'];
92
+ const msg = { payload: { tunnelId: 'tunnel-001', command: 'kill' } };
93
+ inputHandler.call(node, msg);
94
+
95
+ expect(mockSendCommand).toHaveBeenCalledWith(PORT, 'tunnel-001', 'kill', {});
96
+ expect(node.send).toHaveBeenCalledTimes(1);
97
+ expect(node.send).toHaveBeenCalledWith([msg, null]);
98
+ expect(msg.payload).toBe(true);
99
+ expect(msg.sent).toBe(true);
100
+ expect(msg.tunnelId).toBe('tunnel-001');
101
+ expect(msg.command).toBe('kill');
102
+ });
103
+
104
+ test('sends command to output 1 when sendCommand returns false', () => {
105
+ mockSendCommand.mockReturnValue(false);
106
+
107
+ const RED = createMockRED();
108
+ const tunnelModule = require(WS_TUNNEL_PATH);
109
+ tunnelModule(RED);
110
+
111
+ const WSTunnelCommand = RED._registeredTypes['wstunnel command'];
112
+ const node = {};
113
+ WSTunnelCommand.call(node, { wstunnel: 'test-config-id', name: 'cmd' });
114
+
115
+ const inputHandler = node._handlers['input'];
116
+ const msg = { payload: { tunnelId: 'tunnel-001', command: 'kill' } };
117
+ inputHandler.call(node, msg);
118
+
119
+ expect(node.send).toHaveBeenCalledTimes(1);
120
+ expect(node.send).toHaveBeenCalledWith([null, msg]);
121
+ expect(msg.payload).toBe(false);
122
+ expect(msg.sent).toBe(false);
123
+ expect(msg.error).toBe('Tunnel not found or not connected');
124
+ });
125
+
126
+ test('sends to output 1 when tunnelId is missing', () => {
127
+ const RED = createMockRED();
128
+ const tunnelModule = require(WS_TUNNEL_PATH);
129
+ tunnelModule(RED);
130
+
131
+ const WSTunnelCommand = RED._registeredTypes['wstunnel command'];
132
+ const node = {};
133
+ WSTunnelCommand.call(node, { wstunnel: 'test-config-id', name: 'cmd' });
134
+
135
+ const inputHandler = node._handlers['input'];
136
+ const msg = { payload: { command: 'kill' } };
137
+ inputHandler.call(node, msg);
138
+
139
+ expect(mockSendCommand).not.toHaveBeenCalled();
140
+ expect(node.send).toHaveBeenCalledTimes(1);
141
+ expect(node.send).toHaveBeenCalledWith([null, msg]);
142
+ expect(msg.payload).toBe(false);
143
+ expect(msg.sent).toBe(false);
144
+ expect(msg.error).toBe('Missing tunnelId or command');
145
+ expect(node.error).toHaveBeenCalledWith('Missing tunnelId or command');
146
+ });
147
+
148
+ test('sends to output 1 when command is missing', () => {
149
+ const RED = createMockRED();
150
+ const tunnelModule = require(WS_TUNNEL_PATH);
151
+ tunnelModule(RED);
152
+
153
+ const WSTunnelCommand = RED._registeredTypes['wstunnel command'];
154
+ const node = {};
155
+ WSTunnelCommand.call(node, { wstunnel: 'test-config-id', name: 'cmd' });
156
+
157
+ const inputHandler = node._handlers['input'];
158
+ const msg = { payload: { tunnelId: 'tunnel-001' } };
159
+ inputHandler.call(node, msg);
160
+
161
+ expect(mockSendCommand).not.toHaveBeenCalled();
162
+ expect(node.send).toHaveBeenCalledTimes(1);
163
+ expect(node.send).toHaveBeenCalledWith([null, msg]);
164
+ expect(msg.payload).toBe(false);
165
+ expect(msg.sent).toBe(false);
166
+ expect(msg.error).toBe('Missing tunnelId or command');
167
+ });
168
+
169
+ test('reads tunnelId and command from msg top-level properties', () => {
170
+ const RED = createMockRED();
171
+ const tunnelModule = require(WS_TUNNEL_PATH);
172
+ tunnelModule(RED);
173
+
174
+ const WSTunnelCommand = RED._registeredTypes['wstunnel command'];
175
+ const node = {};
176
+ WSTunnelCommand.call(node, { wstunnel: 'test-config-id', name: 'cmd' });
177
+
178
+ const inputHandler = node._handlers['input'];
179
+ const msg = { tunnelId: 't-42', command: 'restart', payload: {} };
180
+ inputHandler.call(node, msg);
181
+
182
+ expect(mockSendCommand).toHaveBeenCalledWith(PORT, 't-42', 'restart', {});
183
+ expect(node.send).toHaveBeenCalledWith([msg, null]);
184
+ expect(msg.tunnelId).toBe('t-42');
185
+ expect(msg.command).toBe('restart');
186
+ });
187
+
188
+ test('passes args from msg to sendCommand', () => {
189
+ const RED = createMockRED();
190
+ const tunnelModule = require(WS_TUNNEL_PATH);
191
+ tunnelModule(RED);
192
+
193
+ const WSTunnelCommand = RED._registeredTypes['wstunnel command'];
194
+ const node = {};
195
+ WSTunnelCommand.call(node, { wstunnel: 'test-config-id', name: 'cmd' });
196
+
197
+ const inputHandler = node._handlers['input'];
198
+ const msg = { payload: { tunnelId: 't-007', command: 'exec', args: { cmd: 'ls' } } };
199
+ inputHandler.call(node, msg);
200
+
201
+ expect(mockSendCommand).toHaveBeenCalledWith(PORT, 't-007', 'exec', { cmd: 'ls' });
202
+ });
203
+ });
@@ -0,0 +1,234 @@
1
+ /**
2
+ * wstunnel server — Metrics Input (Regression Test)
3
+ *
4
+ * Tests that the wstunnel server node emits a metrics snapshot on its output
5
+ * when any message is received on its input.
6
+ */
7
+
8
+ const path = require('path');
9
+
10
+ const WS_TUNNEL_PATH = path.join(__dirname, '..', 'nodes', 'wsTunnel.js');
11
+
12
+ const PORT = 1880;
13
+
14
+ const mockSnapshot = {
15
+ label: 'tunnel',
16
+ ts: new Date().toISOString(),
17
+ active_tunnels: 2,
18
+ active_tunnel_ids: ['tunnel-001', 'tunnel-002'],
19
+ active_streams: 5,
20
+ bytes_in_total: 1024000,
21
+ bytes_out_total: 512000,
22
+ backpressure_events_total: 3,
23
+ buffered_bytes_total: 4096,
24
+ buffered_bytes_per_tunnel: { 'tunnel-001': 2048, 'tunnel-002': 2048 },
25
+ frame_too_large_total: 0,
26
+ tunnel_disconnect_total: 1,
27
+ heartbeat_timeout_total: 0,
28
+ stream_stall_cleanup_total: 0,
29
+ event_loop_lag_ms: { p50: 1.2, p99: 8.5 },
30
+ tunnels_detail: {
31
+ 'tunnel-001': {
32
+ connectedAt: 1725974400000,
33
+ remoteAddress: '192.168.1.10',
34
+ streamCount: 3,
35
+ peakStreamCount: 5,
36
+ bytesIn: 600000,
37
+ bytesOut: 300000,
38
+ agentVersion: '1.0.0',
39
+ },
40
+ },
41
+ };
42
+
43
+ const mockStopWebSocketServer = jest.fn(() => Promise.resolve());
44
+ const mockStartWebSocketServer = jest.fn((opts) => {
45
+ return Promise.resolve({
46
+ [opts.port]: {
47
+ webSocketServer: {
48
+ on: jest.fn(),
49
+ clients: new Set(),
50
+ },
51
+ websocketTunnels: {},
52
+ },
53
+ });
54
+ });
55
+
56
+ const mockGetMetrics = jest.fn(() => ({
57
+ snapshot: jest.fn(() => mockSnapshot),
58
+ }));
59
+
60
+ const mockLogger = {
61
+ info: jest.fn(),
62
+ warn: jest.fn(),
63
+ error: jest.fn(),
64
+ debug: jest.fn(),
65
+ trace: jest.fn(),
66
+ };
67
+ const mockSetLogContext = jest.fn();
68
+ const mockSetLogLevel = jest.fn();
69
+
70
+ function createMockRED() {
71
+ const registeredTypes = {};
72
+ return {
73
+ nodes: {
74
+ createNode: jest.fn((node) => {
75
+ if (!node.id) node.id = 'node-' + Math.random().toString(36).slice(2, 9);
76
+ node.status = jest.fn();
77
+ node.send = jest.fn();
78
+ node.on = jest.fn((event, handler) => {
79
+ node._handlers = node._handlers || {};
80
+ node._handlers[event] = handler;
81
+ });
82
+ }),
83
+ registerType: jest.fn((type, constructor) => {
84
+ registeredTypes[type] = constructor;
85
+ }),
86
+ getNode: jest.fn(() => ({
87
+ options: {
88
+ name: 'test-tunnel',
89
+ host: 'localhost',
90
+ port: PORT,
91
+ path: '',
92
+ tunnelIdHeaderName: 'x-tunnel-id',
93
+ logLevel: 'info',
94
+ },
95
+ })),
96
+ },
97
+ _registeredTypes: registeredTypes,
98
+ };
99
+ }
100
+
101
+ describe('wstunnel server — metrics input', () => {
102
+ beforeAll(() => {
103
+ const serverModule = require('@remotelinker/reverse-ws-tunnel/server');
104
+ serverModule.stopWebSocketServer = mockStopWebSocketServer;
105
+ serverModule.startWebSocketServer = mockStartWebSocketServer;
106
+
107
+ const utilsModule = require('@remotelinker/reverse-ws-tunnel/utils');
108
+ utilsModule.logger = mockLogger;
109
+ utilsModule.setLogContext = mockSetLogContext;
110
+ utilsModule.setLogLevel = mockSetLogLevel;
111
+ utilsModule.getMetrics = mockGetMetrics;
112
+ });
113
+
114
+ beforeEach(() => {
115
+ mockStopWebSocketServer.mockClear();
116
+ mockStartWebSocketServer.mockClear();
117
+ mockGetMetrics.mockClear();
118
+ mockGetMetrics.mockReturnValue({ snapshot: jest.fn(() => mockSnapshot) });
119
+ mockLogger.info.mockClear();
120
+ mockLogger.error.mockClear();
121
+ mockLogger.debug.mockClear();
122
+ });
123
+
124
+ test('emits metrics snapshot when input message is received', async () => {
125
+ const RED = createMockRED();
126
+ const tunnelModule = require(WS_TUNNEL_PATH);
127
+ tunnelModule(RED);
128
+
129
+ const WSTunnelServer = RED._registeredTypes['wstunnel server'];
130
+ const node = { id: 'testNode' };
131
+ WSTunnelServer.call(node, { wstunnel: 'test-config-id', name: 'test-server' });
132
+
133
+ await new Promise((resolve) => setImmediate(resolve));
134
+
135
+ const inputHandler = node._handlers['input'];
136
+ expect(inputHandler).toBeDefined();
137
+
138
+ const msg = { payload: {} };
139
+ inputHandler.call(node, msg);
140
+
141
+ expect(mockGetMetrics).toHaveBeenCalled();
142
+ expect(node.send).toHaveBeenCalledTimes(1);
143
+ expect(node.send).toHaveBeenCalledWith(msg);
144
+ expect(msg.event).toBe('metrics');
145
+ expect(msg.serverPort).toBe(PORT);
146
+ expect(msg.payload).toEqual(mockSnapshot);
147
+ });
148
+
149
+ test('snapshot contains all expected fields', async () => {
150
+ const RED = createMockRED();
151
+ const tunnelModule = require(WS_TUNNEL_PATH);
152
+ tunnelModule(RED);
153
+
154
+ const WSTunnelServer = RED._registeredTypes['wstunnel server'];
155
+ const node = { id: 'testNode' };
156
+ WSTunnelServer.call(node, { wstunnel: 'test-config-id', name: 'test-server' });
157
+
158
+ await new Promise((resolve) => setImmediate(resolve));
159
+
160
+ const inputHandler = node._handlers['input'];
161
+ const msg = {};
162
+ inputHandler.call(node, msg);
163
+
164
+ const snapshot = msg.payload;
165
+ expect(snapshot).toHaveProperty('active_tunnels');
166
+ expect(snapshot).toHaveProperty('active_tunnel_ids');
167
+ expect(snapshot).toHaveProperty('active_streams');
168
+ expect(snapshot).toHaveProperty('bytes_in_total');
169
+ expect(snapshot).toHaveProperty('bytes_out_total');
170
+ expect(snapshot).toHaveProperty('event_loop_lag_ms');
171
+ expect(snapshot).toHaveProperty('tunnels_detail');
172
+ expect(snapshot).toHaveProperty('ts');
173
+ });
174
+
175
+ test('returns zeroed snapshot when no tunnels are active', async () => {
176
+ const zeroSnapshot = {
177
+ label: 'tunnel',
178
+ ts: new Date().toISOString(),
179
+ active_tunnels: 0,
180
+ active_tunnel_ids: [],
181
+ active_streams: 0,
182
+ bytes_in_total: 0,
183
+ bytes_out_total: 0,
184
+ backpressure_events_total: 0,
185
+ buffered_bytes_total: 0,
186
+ buffered_bytes_per_tunnel: {},
187
+ frame_too_large_total: 0,
188
+ tunnel_disconnect_total: 0,
189
+ heartbeat_timeout_total: 0,
190
+ stream_stall_cleanup_total: 0,
191
+ event_loop_lag_ms: { p50: 0, p99: 0 },
192
+ tunnels_detail: {},
193
+ };
194
+ mockGetMetrics.mockReturnValue({ snapshot: jest.fn(() => zeroSnapshot) });
195
+
196
+ const RED = createMockRED();
197
+ const tunnelModule = require(WS_TUNNEL_PATH);
198
+ tunnelModule(RED);
199
+
200
+ const WSTunnelServer = RED._registeredTypes['wstunnel server'];
201
+ const node = { id: 'testNode' };
202
+ WSTunnelServer.call(node, { wstunnel: 'test-config-id', name: 'test-server' });
203
+
204
+ await new Promise((resolve) => setImmediate(resolve));
205
+
206
+ const inputHandler = node._handlers['input'];
207
+ const msg = {};
208
+ inputHandler.call(node, msg);
209
+
210
+ expect(msg.payload.active_tunnels).toBe(0);
211
+ expect(msg.payload.active_tunnel_ids).toEqual([]);
212
+ expect(msg.payload.tunnels_detail).toEqual({});
213
+ });
214
+
215
+ test('msg.event is "metrics" to distinguish from connect/disconnect', async () => {
216
+ const RED = createMockRED();
217
+ const tunnelModule = require(WS_TUNNEL_PATH);
218
+ tunnelModule(RED);
219
+
220
+ const WSTunnelServer = RED._registeredTypes['wstunnel server'];
221
+ const node = { id: 'testNode' };
222
+ WSTunnelServer.call(node, { wstunnel: 'test-config-id', name: 'test-server' });
223
+
224
+ await new Promise((resolve) => setImmediate(resolve));
225
+
226
+ const inputHandler = node._handlers['input'];
227
+ const msg = {};
228
+ inputHandler.call(node, msg);
229
+
230
+ expect(msg.event).toBe('metrics');
231
+ expect(msg.event).not.toBe('connect');
232
+ expect(msg.event).not.toBe('disconnect');
233
+ });
234
+ });