@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,113 @@
1
+ /**
2
+ * RWT-003 — Node Runtime/Editor Compatibility
3
+ *
4
+ * Every Node-RED node type exposed by the package must have a corresponding
5
+ * editor definition, and the runtime and editor definitions must refer to the
6
+ * same node type.
7
+ */
8
+
9
+ const fs = require('fs');
10
+ const path = require('path');
11
+
12
+ const JS_PATH = path.join(__dirname, '..', 'nodes', 'wsTunnel.js');
13
+ const HTML_PATH = path.join(__dirname, '..', 'nodes', 'wsTunnel.html');
14
+
15
+ function extractJsRegisterTypes(jsContent) {
16
+ const regex = /RED\.nodes\.registerType\(\s*['"](.+?)['"]/g;
17
+ const types = [];
18
+ let match;
19
+ while ((match = regex.exec(jsContent)) !== null) {
20
+ types.push(match[1]);
21
+ }
22
+ return types;
23
+ }
24
+
25
+ function extractHtmlTemplateNames(htmlContent) {
26
+ const regex = /data-template-name="(.+?)"/g;
27
+ const types = [];
28
+ let match;
29
+ while ((match = regex.exec(htmlContent)) !== null) {
30
+ types.push(match[1]);
31
+ }
32
+ return types;
33
+ }
34
+
35
+ function extractHtmlHelpNames(htmlContent) {
36
+ const regex = /data-help-name="(.+?)"/g;
37
+ const types = [];
38
+ let match;
39
+ while ((match = regex.exec(htmlContent)) !== null) {
40
+ types.push(match[1]);
41
+ }
42
+ return types;
43
+ }
44
+
45
+ function extractHtmlRegisterTypes(htmlContent) {
46
+ const regex = /RED\.nodes\.registerType\(\s*['"](.+?)['"]/g;
47
+ const types = [];
48
+ let match;
49
+ while ((match = regex.exec(htmlContent)) !== null) {
50
+ types.push(match[1]);
51
+ }
52
+ return types;
53
+ }
54
+
55
+ describe('RWT-003 — Node Runtime/Editor Compatibility', () => {
56
+ let jsContent;
57
+ let htmlContent;
58
+
59
+ beforeAll(() => {
60
+ jsContent = fs.readFileSync(JS_PATH, 'utf8');
61
+ htmlContent = fs.readFileSync(HTML_PATH, 'utf8');
62
+ });
63
+
64
+ test('js and html register the same node types', () => {
65
+ const jsTypes = extractJsRegisterTypes(jsContent);
66
+ const htmlTypes = extractHtmlRegisterTypes(htmlContent);
67
+
68
+ // Filter out commented-out registrations in HTML
69
+ // HTML comments are <!-- ... -->, so we need to remove them first
70
+ const uncommentedHtml = htmlContent.replace(/<!--[\s\S]*?-->/g, '');
71
+ const activeHtmlTypes = extractHtmlRegisterTypes(uncommentedHtml);
72
+
73
+ expect(jsTypes.sort()).toEqual(activeHtmlTypes.sort());
74
+ });
75
+
76
+ test('every registered type has a corresponding data-template-name', () => {
77
+ const uncommentedHtml = htmlContent.replace(/<!--[\s\S]*?-->/g, '');
78
+ const htmlTypes = extractHtmlTemplateNames(uncommentedHtml);
79
+ const jsTypes = extractJsRegisterTypes(jsContent);
80
+
81
+ for (const jsType of jsTypes) {
82
+ expect(htmlTypes).toContain(jsType);
83
+ }
84
+ });
85
+
86
+ test('every registered type has a corresponding data-help-name', () => {
87
+ const uncommentedHtml = htmlContent.replace(/<!--[\s\S]*?-->/g, '');
88
+ const helpTypes = extractHtmlHelpNames(uncommentedHtml);
89
+ const jsTypes = extractJsRegisterTypes(jsContent);
90
+
91
+ for (const jsType of jsTypes) {
92
+ expect(helpTypes).toContain(jsType);
93
+ }
94
+ });
95
+
96
+ test('template-name and help-name match for each type', () => {
97
+ const uncommentedHtml = htmlContent.replace(/<!--[\s\S]*?-->/g, '');
98
+ const templateTypes = extractHtmlTemplateNames(uncommentedHtml);
99
+ const helpTypes = extractHtmlHelpNames(uncommentedHtml);
100
+
101
+ expect(templateTypes.sort()).toEqual(helpTypes.sort());
102
+ });
103
+
104
+ test('currently supported node types are wstunnel, wstunnel server, and wstunnel command', () => {
105
+ const uncommentedHtml = htmlContent.replace(/<!--[\s\S]*?-->/g, '');
106
+ const jsTypes = extractJsRegisterTypes(jsContent);
107
+ const templateTypes = extractHtmlTemplateNames(uncommentedHtml);
108
+
109
+ const expectedTypes = ['wstunnel', 'wstunnel server', 'wstunnel command'];
110
+ expect(jsTypes.sort()).toEqual(expectedTypes.sort());
111
+ expect(templateTypes.sort()).toEqual(expectedTypes.sort());
112
+ });
113
+ });
@@ -0,0 +1,140 @@
1
+ /**
2
+ * RWT-004 — Persisted Flow Configuration Compatibility
3
+ *
4
+ * Existing Node-RED flows using the currently supported wstunnel configuration
5
+ * properties must remain loadable after a package upgrade, and their persisted
6
+ * configuration values must retain their existing behavior.
7
+ */
8
+
9
+ const fs = require('fs');
10
+ const path = require('path');
11
+
12
+ const JS_PATH = path.join(__dirname, '..', 'nodes', 'wsTunnel.js');
13
+ const HTML_PATH = path.join(__dirname, '..', 'nodes', 'wsTunnel.html');
14
+ const FLOWS_PATH = path.join(__dirname, '..', 'data', 'flows.json');
15
+
16
+ const EXPECTED_PROPERTIES = [
17
+ 'name',
18
+ 'host',
19
+ 'port',
20
+ 'path',
21
+ 'tunnelIdHeaderName',
22
+ 'logLevel',
23
+ ];
24
+
25
+ function extractJsOptions(jsContent) {
26
+ // Match the options object in WSTunnelNode constructor
27
+ // Pattern: this.options = { ... }
28
+ const regex = /this\.options\s*=\s*\{([^}]+)\}/s;
29
+ const match = jsContent.match(regex);
30
+ if (!match) return [];
31
+
32
+ const optionsBlock = match[1];
33
+ const propRegex = /(\w+)\s*:/g;
34
+ const props = [];
35
+ let propMatch;
36
+ while ((propMatch = propRegex.exec(optionsBlock)) !== null) {
37
+ props.push(propMatch[1]);
38
+ }
39
+ return props;
40
+ }
41
+
42
+ function extractHtmlDefaults(htmlContent) {
43
+ // Find the wstunnel config node registration defaults
44
+ // Pattern: RED.nodes.registerType('wstunnel', { ... defaults: { ... } })
45
+ const uncommentedHtml = htmlContent.replace(/<!--[\s\S]*?-->/g, '');
46
+
47
+ // Find the wstunnel registration (not wstunnel server)
48
+ // Use a more precise approach: find the defaults block by counting braces
49
+ const typeIndex = uncommentedHtml.indexOf("RED.nodes.registerType('wstunnel',");
50
+ if (typeIndex === -1) return [];
51
+
52
+ const defaultsIndex = uncommentedHtml.indexOf('defaults:', typeIndex);
53
+ if (defaultsIndex === -1) return [];
54
+
55
+ // Find the opening brace of defaults
56
+ const openBraceIndex = uncommentedHtml.indexOf('{', defaultsIndex);
57
+ if (openBraceIndex === -1) return [];
58
+
59
+ // Count braces to find the matching closing brace
60
+ let depth = 1;
61
+ let i = openBraceIndex + 1;
62
+ while (i < uncommentedHtml.length && depth > 0) {
63
+ if (uncommentedHtml[i] === '{') depth++;
64
+ if (uncommentedHtml[i] === '}') depth--;
65
+ i++;
66
+ }
67
+
68
+ const defaultsBlock = uncommentedHtml.substring(openBraceIndex + 1, i - 1);
69
+
70
+ // Extract property names (keys that have colon followed by {)
71
+ const propRegex = /(\w+)\s*:\s*\{/g;
72
+ const props = [];
73
+ let propMatch;
74
+ while ((propMatch = propRegex.exec(defaultsBlock)) !== null) {
75
+ props.push(propMatch[1]);
76
+ }
77
+ return props;
78
+ }
79
+
80
+ function extractFlowProperties(flowsContent) {
81
+ const flows = JSON.parse(flowsContent);
82
+ const wstunnelNode = flows.find((f) => f.type === 'wstunnel');
83
+ if (!wstunnelNode) return [];
84
+ return Object.keys(wstunnelNode).filter((k) => k !== 'id' && k !== 'type');
85
+ }
86
+
87
+ describe('RWT-004 — Persisted Flow Configuration Compatibility', () => {
88
+ let jsContent;
89
+ let htmlContent;
90
+
91
+ beforeAll(() => {
92
+ jsContent = fs.readFileSync(JS_PATH, 'utf8');
93
+ htmlContent = fs.readFileSync(HTML_PATH, 'utf8');
94
+ });
95
+
96
+ test('WSTunnelNode constructor defines all expected properties', () => {
97
+ const jsProps = extractJsOptions(jsContent);
98
+
99
+ for (const prop of EXPECTED_PROPERTIES) {
100
+ expect(jsProps).toContain(prop);
101
+ }
102
+ });
103
+
104
+ test('html registration defines all expected properties', () => {
105
+ const htmlProps = extractHtmlDefaults(htmlContent);
106
+
107
+ for (const prop of EXPECTED_PROPERTIES) {
108
+ expect(htmlProps).toContain(prop);
109
+ }
110
+ });
111
+
112
+ test('js and html define the same set of properties', () => {
113
+ const jsProps = extractJsOptions(jsContent);
114
+ const htmlProps = extractHtmlDefaults(htmlContent);
115
+
116
+ expect(jsProps.sort()).toEqual(htmlProps.sort());
117
+ });
118
+
119
+ test('flows.json contains all expected persisted properties', () => {
120
+ if (!fs.existsSync(FLOWS_PATH)) {
121
+ // Skip if flows.json doesn't exist (e.g., in CI)
122
+ return;
123
+ }
124
+
125
+ const flowsContent = fs.readFileSync(FLOWS_PATH, 'utf8');
126
+ const flowProps = extractFlowProperties(flowsContent);
127
+
128
+ for (const prop of EXPECTED_PROPERTIES) {
129
+ expect(flowProps).toContain(prop);
130
+ }
131
+ });
132
+
133
+ test('no expected property has been removed from the options object', () => {
134
+ const jsProps = extractJsOptions(jsContent);
135
+
136
+ // This test will fail if any expected property is removed
137
+ expect(jsProps).toEqual(expect.arrayContaining(EXPECTED_PROPERTIES));
138
+ expect(jsProps.length).toBe(EXPECTED_PROPERTIES.length);
139
+ });
140
+ });
@@ -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
+ });