@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.
- package/AGENTS.md +45 -0
- package/CHANGELOG.md +109 -0
- package/README.md +83 -4
- package/STABILITY_CONTRACT.md +299 -0
- package/nodes/wsTunnel.html +126 -4
- package/nodes/wsTunnel.js +143 -68
- package/package.json +4 -12
- package/test/ki-001-host-path-passthrough.test.js +187 -0
- package/test/ki-002-tunnel-id-header-default.test.js +60 -0
- package/test/ki-003-debug-log-level.test.js +180 -0
- package/test/ki-004-console-error-usage.test.js +144 -0
- package/test/m-3-concurrent-deployment.test.js +227 -0
- package/test/output-events.test.js +241 -0
- package/test/rwt-001-port-exclusivity.test.js +171 -0
- package/test/rwt-001-port-release.test.js +58 -0
- package/test/rwt-002-shutdown-completion.test.js +226 -0
- package/test/rwt-003-runtime-editor-compatibility.test.js +113 -0
- package/test/rwt-004-persisted-config-compatibility.test.js +140 -0
- package/test/start-failure-path.test.js +212 -0
- package/test/wstunnel-command.test.js +203 -0
- package/test/wstunnel-metrics-input.test.js +234 -0
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* wstunnel server — Output Events (Regression Test)
|
|
3
|
+
*
|
|
4
|
+
* Tests that the wstunnel server node emits connect and disconnect events
|
|
5
|
+
* on its output when tunnels register (CONFIG handshake) and close.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const path = require('path');
|
|
9
|
+
const { EventEmitter } = require('events');
|
|
10
|
+
|
|
11
|
+
const WS_TUNNEL_PATH = path.join(__dirname, '..', 'nodes', 'wsTunnel.js');
|
|
12
|
+
|
|
13
|
+
const PORT = 1880;
|
|
14
|
+
const TUNNEL_ID = 'test-tunnel-uuid-001';
|
|
15
|
+
|
|
16
|
+
let serverHandlers = {};
|
|
17
|
+
let mockTunnels = {};
|
|
18
|
+
|
|
19
|
+
const mockStopWebSocketServer = jest.fn(() => Promise.resolve());
|
|
20
|
+
|
|
21
|
+
const mockStartWebSocketServer = jest.fn((opts) => {
|
|
22
|
+
serverHandlers = {};
|
|
23
|
+
mockTunnels = {};
|
|
24
|
+
return Promise.resolve({
|
|
25
|
+
[opts.port]: {
|
|
26
|
+
webSocketServer: {
|
|
27
|
+
on: jest.fn((event, handler) => {
|
|
28
|
+
serverHandlers[event] = handler;
|
|
29
|
+
}),
|
|
30
|
+
clients: new Set(),
|
|
31
|
+
},
|
|
32
|
+
websocketTunnels: mockTunnels,
|
|
33
|
+
},
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
const mockLogger = {
|
|
38
|
+
info: jest.fn(),
|
|
39
|
+
warn: jest.fn(),
|
|
40
|
+
error: jest.fn(),
|
|
41
|
+
debug: jest.fn(),
|
|
42
|
+
trace: jest.fn(),
|
|
43
|
+
};
|
|
44
|
+
const mockSetLogContext = jest.fn();
|
|
45
|
+
const mockSetLogLevel = jest.fn();
|
|
46
|
+
|
|
47
|
+
function createMockRED() {
|
|
48
|
+
const registeredTypes = {};
|
|
49
|
+
return {
|
|
50
|
+
nodes: {
|
|
51
|
+
createNode: jest.fn((node) => {
|
|
52
|
+
if (!node.id) node.id = 'node-' + Math.random().toString(36).slice(2, 9);
|
|
53
|
+
node.status = jest.fn();
|
|
54
|
+
node.send = jest.fn();
|
|
55
|
+
node.on = jest.fn((event, handler) => {
|
|
56
|
+
node._handlers = node._handlers || {};
|
|
57
|
+
node._handlers[event] = handler;
|
|
58
|
+
});
|
|
59
|
+
}),
|
|
60
|
+
registerType: jest.fn((type, constructor) => {
|
|
61
|
+
registeredTypes[type] = constructor;
|
|
62
|
+
}),
|
|
63
|
+
getNode: jest.fn(() => ({
|
|
64
|
+
options: {
|
|
65
|
+
name: 'test-tunnel',
|
|
66
|
+
host: 'localhost',
|
|
67
|
+
port: PORT,
|
|
68
|
+
path: '',
|
|
69
|
+
tunnelIdHeaderName: 'x-tunnel-id',
|
|
70
|
+
logLevel: 'info',
|
|
71
|
+
},
|
|
72
|
+
})),
|
|
73
|
+
},
|
|
74
|
+
_registeredTypes: registeredTypes,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function createMockWs(remoteAddress = '192.168.1.10') {
|
|
79
|
+
const ws = new EventEmitter();
|
|
80
|
+
ws._socket = { remoteAddress, remotePort: 54321 };
|
|
81
|
+
ws.readyState = 1;
|
|
82
|
+
return ws;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
describe('wstunnel server — output events', () => {
|
|
86
|
+
beforeAll(() => {
|
|
87
|
+
const serverModule = require('@remotelinker/reverse-ws-tunnel/server');
|
|
88
|
+
serverModule.stopWebSocketServer = mockStopWebSocketServer;
|
|
89
|
+
serverModule.startWebSocketServer = mockStartWebSocketServer;
|
|
90
|
+
|
|
91
|
+
const utilsModule = require('@remotelinker/reverse-ws-tunnel/utils');
|
|
92
|
+
utilsModule.logger = mockLogger;
|
|
93
|
+
utilsModule.setLogContext = mockSetLogContext;
|
|
94
|
+
utilsModule.setLogLevel = mockSetLogLevel;
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
beforeEach(() => {
|
|
98
|
+
serverHandlers = {};
|
|
99
|
+
mockTunnels = {};
|
|
100
|
+
mockStopWebSocketServer.mockClear();
|
|
101
|
+
mockStartWebSocketServer.mockClear();
|
|
102
|
+
mockLogger.info.mockClear();
|
|
103
|
+
mockLogger.error.mockClear();
|
|
104
|
+
mockLogger.debug.mockClear();
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test('emits connect event when tunnel registers via CONFIG', async () => {
|
|
108
|
+
const RED = createMockRED();
|
|
109
|
+
const tunnelModule = require(WS_TUNNEL_PATH);
|
|
110
|
+
tunnelModule(RED);
|
|
111
|
+
|
|
112
|
+
const WSTunnelServer = RED._registeredTypes['wstunnel server'];
|
|
113
|
+
const node = { id: 'testNode' };
|
|
114
|
+
WSTunnelServer.call(node, { wstunnel: 'test-config-id', name: 'test-server' });
|
|
115
|
+
|
|
116
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
117
|
+
|
|
118
|
+
const mockWs = createMockWs('10.0.0.1');
|
|
119
|
+
const connectedAt = Date.now();
|
|
120
|
+
|
|
121
|
+
mockTunnels[TUNNEL_ID] = { ws: mockWs };
|
|
122
|
+
|
|
123
|
+
serverHandlers['connection'](mockWs);
|
|
124
|
+
|
|
125
|
+
mockWs.emit('message', Buffer.from('config'));
|
|
126
|
+
|
|
127
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
128
|
+
|
|
129
|
+
expect(node.send).toHaveBeenCalledTimes(1);
|
|
130
|
+
expect(node.send).toHaveBeenCalledWith({
|
|
131
|
+
event: 'connect',
|
|
132
|
+
tunnelId: TUNNEL_ID,
|
|
133
|
+
remoteAddress: '10.0.0.1',
|
|
134
|
+
connectedAt: expect.any(Number),
|
|
135
|
+
serverPort: PORT,
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test('emits disconnect event when registered tunnel closes', async () => {
|
|
140
|
+
const RED = createMockRED();
|
|
141
|
+
const tunnelModule = require(WS_TUNNEL_PATH);
|
|
142
|
+
tunnelModule(RED);
|
|
143
|
+
|
|
144
|
+
const WSTunnelServer = RED._registeredTypes['wstunnel server'];
|
|
145
|
+
const node = { id: 'testNode' };
|
|
146
|
+
WSTunnelServer.call(node, { wstunnel: 'test-config-id', name: 'test-server' });
|
|
147
|
+
|
|
148
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
149
|
+
|
|
150
|
+
const mockWs = createMockWs('10.0.0.2');
|
|
151
|
+
mockTunnels[TUNNEL_ID] = { ws: mockWs };
|
|
152
|
+
|
|
153
|
+
serverHandlers['connection'](mockWs);
|
|
154
|
+
mockWs.emit('message', Buffer.from('config'));
|
|
155
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
156
|
+
|
|
157
|
+
node.send.mockClear();
|
|
158
|
+
|
|
159
|
+
mockWs.emit('close');
|
|
160
|
+
|
|
161
|
+
expect(node.send).toHaveBeenCalledTimes(1);
|
|
162
|
+
expect(node.send).toHaveBeenCalledWith({
|
|
163
|
+
event: 'disconnect',
|
|
164
|
+
tunnelId: TUNNEL_ID,
|
|
165
|
+
remoteAddress: '10.0.0.2',
|
|
166
|
+
serverPort: PORT,
|
|
167
|
+
});
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
test('does not emit events for connections without CONFIG', async () => {
|
|
171
|
+
const RED = createMockRED();
|
|
172
|
+
const tunnelModule = require(WS_TUNNEL_PATH);
|
|
173
|
+
tunnelModule(RED);
|
|
174
|
+
|
|
175
|
+
const WSTunnelServer = RED._registeredTypes['wstunnel server'];
|
|
176
|
+
const node = { id: 'testNode' };
|
|
177
|
+
WSTunnelServer.call(node, { wstunnel: 'test-config-id', name: 'test-server' });
|
|
178
|
+
|
|
179
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
180
|
+
|
|
181
|
+
const mockWs = createMockWs('10.0.0.3');
|
|
182
|
+
|
|
183
|
+
serverHandlers['connection'](mockWs);
|
|
184
|
+
|
|
185
|
+
mockWs.emit('close');
|
|
186
|
+
|
|
187
|
+
expect(node.send).not.toHaveBeenCalled();
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
test('connect event contains all expected fields', async () => {
|
|
191
|
+
const RED = createMockRED();
|
|
192
|
+
const tunnelModule = require(WS_TUNNEL_PATH);
|
|
193
|
+
tunnelModule(RED);
|
|
194
|
+
|
|
195
|
+
const WSTunnelServer = RED._registeredTypes['wstunnel server'];
|
|
196
|
+
const node = { id: 'testNode' };
|
|
197
|
+
WSTunnelServer.call(node, { wstunnel: 'test-config-id', name: 'test-server' });
|
|
198
|
+
|
|
199
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
200
|
+
|
|
201
|
+
const mockWs = createMockWs('172.16.0.5');
|
|
202
|
+
mockTunnels[TUNNEL_ID] = { ws: mockWs };
|
|
203
|
+
|
|
204
|
+
serverHandlers['connection'](mockWs);
|
|
205
|
+
mockWs.emit('message', Buffer.from('config'));
|
|
206
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
207
|
+
|
|
208
|
+
const sentMsg = node.send.mock.calls[0][0];
|
|
209
|
+
expect(sentMsg).toHaveProperty('event', 'connect');
|
|
210
|
+
expect(sentMsg).toHaveProperty('tunnelId', TUNNEL_ID);
|
|
211
|
+
expect(sentMsg).toHaveProperty('remoteAddress', '172.16.0.5');
|
|
212
|
+
expect(sentMsg).toHaveProperty('connectedAt');
|
|
213
|
+
expect(typeof sentMsg.connectedAt).toBe('number');
|
|
214
|
+
expect(sentMsg).toHaveProperty('serverPort', PORT);
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
test('disconnect event is not emitted for unconfirmed connections', async () => {
|
|
218
|
+
const RED = createMockRED();
|
|
219
|
+
const tunnelModule = require(WS_TUNNEL_PATH);
|
|
220
|
+
tunnelModule(RED);
|
|
221
|
+
|
|
222
|
+
const WSTunnelServer = RED._registeredTypes['wstunnel server'];
|
|
223
|
+
const node = { id: 'testNode' };
|
|
224
|
+
WSTunnelServer.call(node, { wstunnel: 'test-config-id', name: 'test-server' });
|
|
225
|
+
|
|
226
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
227
|
+
|
|
228
|
+
const mockWs = createMockWs('10.0.0.4');
|
|
229
|
+
|
|
230
|
+
serverHandlers['connection'](mockWs);
|
|
231
|
+
|
|
232
|
+
mockWs.emit('message', Buffer.from('config'));
|
|
233
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
234
|
+
|
|
235
|
+
node.send.mockClear();
|
|
236
|
+
|
|
237
|
+
mockWs.emit('close');
|
|
238
|
+
|
|
239
|
+
expect(node.send).not.toHaveBeenCalled();
|
|
240
|
+
});
|
|
241
|
+
});
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RWT-001 — Exclusive Server Ownership of a Port
|
|
3
|
+
*
|
|
4
|
+
* A replacement managed WebSocket server must not be started on a port until
|
|
5
|
+
* the previously managed server on that port has completed its shutdown.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const path = require('path');
|
|
9
|
+
|
|
10
|
+
const WS_TUNNEL_PATH = path.join(__dirname, '..', 'nodes', 'wsTunnel.js');
|
|
11
|
+
|
|
12
|
+
// Track call order across mocks
|
|
13
|
+
let callLog = [];
|
|
14
|
+
|
|
15
|
+
// Mock stopWebSocketServer
|
|
16
|
+
const mockStopWebSocketServer = jest.fn((port) => {
|
|
17
|
+
callLog.push({ fn: 'stopWebSocketServer', port });
|
|
18
|
+
return new Promise((resolve) => {
|
|
19
|
+
setImmediate(() => {
|
|
20
|
+
callLog.push({ fn: 'stopWebSocketServer-resolved', port });
|
|
21
|
+
resolve();
|
|
22
|
+
});
|
|
23
|
+
});
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
// Mock startWebSocketServer
|
|
27
|
+
const mockStartWebSocketServer = jest.fn((opts) => {
|
|
28
|
+
callLog.push({ fn: 'startWebSocketServer', port: opts.port });
|
|
29
|
+
return Promise.resolve({
|
|
30
|
+
[opts.port]: {
|
|
31
|
+
webSocketServer: {
|
|
32
|
+
on: jest.fn(),
|
|
33
|
+
clients: new Set(),
|
|
34
|
+
},
|
|
35
|
+
},
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
// Mock logger
|
|
40
|
+
const mockLogger = {
|
|
41
|
+
info: jest.fn(),
|
|
42
|
+
warn: jest.fn(),
|
|
43
|
+
error: jest.fn(),
|
|
44
|
+
debug: jest.fn(),
|
|
45
|
+
trace: jest.fn(),
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
// Mock setLogContext and setLogLevel
|
|
49
|
+
const mockSetLogContext = jest.fn();
|
|
50
|
+
const mockSetLogLevel = jest.fn();
|
|
51
|
+
|
|
52
|
+
// Mock RED object
|
|
53
|
+
function createMockRED() {
|
|
54
|
+
const registeredTypes = {};
|
|
55
|
+
return {
|
|
56
|
+
nodes: {
|
|
57
|
+
createNode: jest.fn((node, config) => {
|
|
58
|
+
node.status = jest.fn();
|
|
59
|
+
node.on = jest.fn((event, handler) => {
|
|
60
|
+
node._handlers = node._handlers || {};
|
|
61
|
+
node._handlers[event] = handler;
|
|
62
|
+
});
|
|
63
|
+
}),
|
|
64
|
+
registerType: jest.fn((type, constructor) => {
|
|
65
|
+
registeredTypes[type] = constructor;
|
|
66
|
+
}),
|
|
67
|
+
getNode: jest.fn((id) => ({
|
|
68
|
+
options: {
|
|
69
|
+
name: 'test-tunnel',
|
|
70
|
+
host: 'localhost',
|
|
71
|
+
port: 1880,
|
|
72
|
+
path: '',
|
|
73
|
+
tunnelIdHeaderName: 'x-tunnel-id',
|
|
74
|
+
logLevel: 'info',
|
|
75
|
+
},
|
|
76
|
+
})),
|
|
77
|
+
},
|
|
78
|
+
_registeredTypes: registeredTypes,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
describe('RWT-001 — Exclusive Server Ownership of a Port', () => {
|
|
83
|
+
let originalModules;
|
|
84
|
+
|
|
85
|
+
beforeAll(() => {
|
|
86
|
+
originalModules = {
|
|
87
|
+
stopWebSocketServer: require('@remotelinker/reverse-ws-tunnel/server').stopWebSocketServer,
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
// Replace the module exports before requiring wsTunnel
|
|
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
|
+
callLog = [];
|
|
103
|
+
mockStopWebSocketServer.mockClear();
|
|
104
|
+
mockStartWebSocketServer.mockClear();
|
|
105
|
+
mockLogger.info.mockClear();
|
|
106
|
+
mockSetLogContext.mockClear();
|
|
107
|
+
mockSetLogLevel.mockClear();
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
afterAll(() => {
|
|
111
|
+
// Restore original modules
|
|
112
|
+
const serverModule = require('@remotelinker/reverse-ws-tunnel/server');
|
|
113
|
+
serverModule.stopWebSocketServer = originalModules.stopWebSocketServer;
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test('stopWebSocketServer is called before startWebSocketServer on node creation', async () => {
|
|
117
|
+
const RED = createMockRED();
|
|
118
|
+
const tunnelModule = require(WS_TUNNEL_PATH);
|
|
119
|
+
tunnelModule(RED);
|
|
120
|
+
|
|
121
|
+
// Get the registered WSTunnelServer constructor
|
|
122
|
+
const WSTunnelServer = RED._registeredTypes['wstunnel server'];
|
|
123
|
+
expect(WSTunnelServer).toBeDefined();
|
|
124
|
+
|
|
125
|
+
// Create a mock node config
|
|
126
|
+
const nodeConfig = {
|
|
127
|
+
wstunnel: 'test-config-id',
|
|
128
|
+
name: 'test-server',
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
// Instantiate the node
|
|
132
|
+
const node = {};
|
|
133
|
+
WSTunnelServer.call(node, nodeConfig);
|
|
134
|
+
|
|
135
|
+
// Wait for the constructor promise chain (mockStopWebSocketServer resolves via setImmediate)
|
|
136
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
137
|
+
|
|
138
|
+
// Verify stopWebSocketServer was called
|
|
139
|
+
expect(mockStopWebSocketServer).toHaveBeenCalled();
|
|
140
|
+
|
|
141
|
+
// Verify startWebSocketServer was called
|
|
142
|
+
expect(mockStartWebSocketServer).toHaveBeenCalled();
|
|
143
|
+
|
|
144
|
+
// Verify ordering: stopWebSocketServer must complete before startWebSocketServer
|
|
145
|
+
const stopIndex = callLog.findIndex((e) => e.fn === 'stopWebSocketServer-resolved');
|
|
146
|
+
const startIndex = callLog.findIndex((e) => e.fn === 'startWebSocketServer');
|
|
147
|
+
|
|
148
|
+
expect(stopIndex).toBeGreaterThanOrEqual(0);
|
|
149
|
+
expect(startIndex).toBeGreaterThanOrEqual(0);
|
|
150
|
+
expect(stopIndex).toBeLessThan(startIndex);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test('the correct port is passed to both stop and start', async () => {
|
|
154
|
+
const RED = createMockRED();
|
|
155
|
+
const tunnelModule = require(WS_TUNNEL_PATH);
|
|
156
|
+
tunnelModule(RED);
|
|
157
|
+
|
|
158
|
+
const WSTunnelServer = RED._registeredTypes['wstunnel server'];
|
|
159
|
+
const node = {};
|
|
160
|
+
WSTunnelServer.call(node, { wstunnel: 'test-config-id', name: 'test-server' });
|
|
161
|
+
|
|
162
|
+
// Wait for the constructor promise chain (mockStopWebSocketServer resolves via setImmediate)
|
|
163
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
164
|
+
|
|
165
|
+
const stopCall = mockStopWebSocketServer.mock.calls[0];
|
|
166
|
+
const startCall = mockStartWebSocketServer.mock.calls[0];
|
|
167
|
+
|
|
168
|
+
expect(stopCall[0]).toBe(1880);
|
|
169
|
+
expect(startCall[0].port).toBe(1880);
|
|
170
|
+
});
|
|
171
|
+
});
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RWT-001 — Port Release Guarantee (Deterministic)
|
|
3
|
+
*
|
|
4
|
+
* stopWebSocketServer(port) must release the port before resolving.
|
|
5
|
+
* A replacement server must bind to the same port without EADDRINUSE.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const { startWebSocketServer, stopWebSocketServer } = require('@remotelinker/reverse-ws-tunnel/server');
|
|
9
|
+
|
|
10
|
+
const TEST_PORT = 18999;
|
|
11
|
+
|
|
12
|
+
describe('RWT-001 — Port release after stopWebSocketServer', () => {
|
|
13
|
+
afterEach(async () => {
|
|
14
|
+
await stopWebSocketServer(TEST_PORT);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
test('port is available immediately after stopWebSocketServer resolves', async () => {
|
|
18
|
+
const state1 = await startWebSocketServer({
|
|
19
|
+
port: TEST_PORT,
|
|
20
|
+
tunnelIdHeaderName: 'x-tunnel-id',
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
expect(state1[TEST_PORT]).toBeDefined();
|
|
24
|
+
expect(state1[TEST_PORT].webSocketServer).toBeDefined();
|
|
25
|
+
|
|
26
|
+
await stopWebSocketServer(TEST_PORT);
|
|
27
|
+
|
|
28
|
+
const state2 = await startWebSocketServer({
|
|
29
|
+
port: TEST_PORT,
|
|
30
|
+
tunnelIdHeaderName: 'x-tunnel-id',
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
expect(state2[TEST_PORT]).toBeDefined();
|
|
34
|
+
expect(state2[TEST_PORT].webSocketServer).toBeDefined();
|
|
35
|
+
|
|
36
|
+
await stopWebSocketServer(TEST_PORT);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test('rapid stop-then-start cycle does not produce EADDRINUSE', async () => {
|
|
40
|
+
const state1 = await startWebSocketServer({
|
|
41
|
+
port: TEST_PORT,
|
|
42
|
+
tunnelIdHeaderName: 'x-tunnel-id',
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
expect(state1[TEST_PORT]).toBeDefined();
|
|
46
|
+
|
|
47
|
+
for (let i = 0; i < 3; i++) {
|
|
48
|
+
await stopWebSocketServer(TEST_PORT);
|
|
49
|
+
const state = await startWebSocketServer({
|
|
50
|
+
port: TEST_PORT,
|
|
51
|
+
tunnelIdHeaderName: 'x-tunnel-id',
|
|
52
|
+
});
|
|
53
|
+
expect(state[TEST_PORT]).toBeDefined();
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
await stopWebSocketServer(TEST_PORT);
|
|
57
|
+
});
|
|
58
|
+
});
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RWT-002 — Node Shutdown Must Signal Completion
|
|
3
|
+
*
|
|
4
|
+
* When a WSTunnelServer node receives a Node-RED close event, its shutdown
|
|
5
|
+
* handler must always signal completion by invoking done(), including failure paths.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const path = require('path');
|
|
9
|
+
|
|
10
|
+
const WS_TUNNEL_PATH = path.join(__dirname, '..', 'nodes', 'wsTunnel.js');
|
|
11
|
+
|
|
12
|
+
// Mock state
|
|
13
|
+
let mockStopResolved = true;
|
|
14
|
+
|
|
15
|
+
// Mock stopWebSocketServer
|
|
16
|
+
const mockStopWebSocketServer = jest.fn((port) => {
|
|
17
|
+
return new Promise((resolve, reject) => {
|
|
18
|
+
setImmediate(() => {
|
|
19
|
+
if (mockStopResolved) {
|
|
20
|
+
resolve();
|
|
21
|
+
} else {
|
|
22
|
+
reject(new Error('Stop failed'));
|
|
23
|
+
}
|
|
24
|
+
});
|
|
25
|
+
});
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
// Mock startWebSocketServer
|
|
29
|
+
const mockStartWebSocketServer = jest.fn((opts) => {
|
|
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((id) => ({
|
|
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
|
+
function createNode(RED) {
|
|
84
|
+
const WSTunnelServer = RED._registeredTypes['wstunnel server'];
|
|
85
|
+
const node = {};
|
|
86
|
+
WSTunnelServer.call(node, { wstunnel: 'test-config-id', name: 'test-server' });
|
|
87
|
+
return node;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
describe('RWT-002 — Node Shutdown Must Signal Completion', () => {
|
|
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
|
+
mockStopResolved = true;
|
|
104
|
+
mockStopWebSocketServer.mockClear();
|
|
105
|
+
mockStartWebSocketServer.mockClear();
|
|
106
|
+
mockLogger.info.mockClear();
|
|
107
|
+
mockSetLogContext.mockClear();
|
|
108
|
+
mockSetLogLevel.mockClear();
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test('done() is called on successful stopWebSocketServer', async () => {
|
|
112
|
+
const RED = createMockRED();
|
|
113
|
+
const tunnelModule = require(WS_TUNNEL_PATH);
|
|
114
|
+
tunnelModule(RED);
|
|
115
|
+
|
|
116
|
+
// Wait for the constructor promise chain (mockStopWebSocketServer resolves via setImmediate)
|
|
117
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
118
|
+
|
|
119
|
+
const node = createNode(RED);
|
|
120
|
+
const done = jest.fn();
|
|
121
|
+
|
|
122
|
+
// Simulate the close event
|
|
123
|
+
node._handlers.close.call(node, false, done);
|
|
124
|
+
|
|
125
|
+
// Wait for the close handler promise chain (mockStopWebSocketServer resolves via setImmediate)
|
|
126
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
127
|
+
|
|
128
|
+
expect(done).toHaveBeenCalledTimes(1);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test('done() is called on failed stopWebSocketServer', async () => {
|
|
132
|
+
mockStopResolved = false;
|
|
133
|
+
|
|
134
|
+
const RED = createMockRED();
|
|
135
|
+
const tunnelModule = require(WS_TUNNEL_PATH);
|
|
136
|
+
tunnelModule(RED);
|
|
137
|
+
|
|
138
|
+
// Wait for the constructor promise chain (mockStopWebSocketServer resolves via setImmediate)
|
|
139
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
140
|
+
|
|
141
|
+
const node = createNode(RED);
|
|
142
|
+
const done = jest.fn();
|
|
143
|
+
|
|
144
|
+
// Simulate the close event
|
|
145
|
+
node._handlers.close.call(node, false, done);
|
|
146
|
+
|
|
147
|
+
// Wait for the close handler promise chain (mockStopWebSocketServer resolves via setImmediate)
|
|
148
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
149
|
+
|
|
150
|
+
expect(done).toHaveBeenCalledTimes(1);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test('done() is called when no server state exists', async () => {
|
|
154
|
+
const RED = createMockRED();
|
|
155
|
+
const tunnelModule = require(WS_TUNNEL_PATH);
|
|
156
|
+
tunnelModule(RED);
|
|
157
|
+
|
|
158
|
+
// Create a node without waiting for server setup
|
|
159
|
+
const node = {};
|
|
160
|
+
RED.nodes.createNode = jest.fn((n, config) => {
|
|
161
|
+
n.status = jest.fn();
|
|
162
|
+
n.on = jest.fn((event, handler) => {
|
|
163
|
+
n._handlers = n._handlers || {};
|
|
164
|
+
n._handlers[event] = handler;
|
|
165
|
+
});
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
const WSTunnelServer = RED._registeredTypes['wstunnel server'];
|
|
169
|
+
WSTunnelServer.call(node, { wstunnel: 'test-config-id', name: 'test-server' });
|
|
170
|
+
|
|
171
|
+
// Don't wait for server setup — close immediately
|
|
172
|
+
const done = jest.fn();
|
|
173
|
+
node._handlers.close.call(node, false, done);
|
|
174
|
+
|
|
175
|
+
// The close handler calls done() synchronously when no server state exists
|
|
176
|
+
// A single microtask flush is sufficient
|
|
177
|
+
await Promise.resolve();
|
|
178
|
+
|
|
179
|
+
expect(done).toHaveBeenCalledTimes(1);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
test('done() is never called more than once', async () => {
|
|
183
|
+
const RED = createMockRED();
|
|
184
|
+
const tunnelModule = require(WS_TUNNEL_PATH);
|
|
185
|
+
tunnelModule(RED);
|
|
186
|
+
|
|
187
|
+
// Wait for the constructor promise chain (mockStopWebSocketServer resolves via setImmediate)
|
|
188
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
189
|
+
|
|
190
|
+
const node = createNode(RED);
|
|
191
|
+
const done = jest.fn();
|
|
192
|
+
|
|
193
|
+
node._handlers.close.call(node, false, done);
|
|
194
|
+
|
|
195
|
+
// Wait for the close handler promise chain (mockStopWebSocketServer resolves via setImmediate)
|
|
196
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
197
|
+
|
|
198
|
+
expect(done).toHaveBeenCalledTimes(1);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
test('cleanup completes before done() is called', async () => {
|
|
202
|
+
const RED = createMockRED();
|
|
203
|
+
const tunnelModule = require(WS_TUNNEL_PATH);
|
|
204
|
+
tunnelModule(RED);
|
|
205
|
+
|
|
206
|
+
const node = createNode(RED);
|
|
207
|
+
|
|
208
|
+
// Wait for the constructor promise chain (mockStopWebSocketServer resolves via setImmediate)
|
|
209
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
210
|
+
|
|
211
|
+
const done = jest.fn();
|
|
212
|
+
|
|
213
|
+
// Call close handler — stopWebSocketServer starts but hasn't resolved yet
|
|
214
|
+
node._handlers.close.call(node, false, done);
|
|
215
|
+
|
|
216
|
+
// done() must NOT be called synchronously (before the promise chain resolves)
|
|
217
|
+
// The mock resolves via setImmediate, which hasn't fired yet at this point
|
|
218
|
+
expect(done).not.toHaveBeenCalled();
|
|
219
|
+
|
|
220
|
+
// Flush the promise chain
|
|
221
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
222
|
+
|
|
223
|
+
// Now done() should have been called
|
|
224
|
+
expect(done).toHaveBeenCalledTimes(1);
|
|
225
|
+
});
|
|
226
|
+
});
|