@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.
- package/AGENTS.md +45 -0
- package/CHANGELOG.md +27 -0
- package/README.md +83 -4
- package/STABILITY_CONTRACT.md +299 -0
- package/nodes/wsTunnel.html +126 -4
- package/nodes/wsTunnel.js +99 -6
- 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,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
|
+
});
|
|
@@ -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
|
+
});
|