@desplega.ai/qa-use 2.0.1

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.
Files changed (117) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +1003 -0
  3. package/bin/qa-use.js +7 -0
  4. package/dist/lib/api/index.d.ts +296 -0
  5. package/dist/lib/api/index.d.ts.map +1 -0
  6. package/dist/lib/api/index.js +564 -0
  7. package/dist/lib/api/index.js.map +1 -0
  8. package/dist/lib/api/sse.d.ts +33 -0
  9. package/dist/lib/api/sse.d.ts.map +1 -0
  10. package/dist/lib/api/sse.js +97 -0
  11. package/dist/lib/api/sse.js.map +1 -0
  12. package/dist/lib/browser/index.d.ts +28 -0
  13. package/dist/lib/browser/index.d.ts.map +1 -0
  14. package/dist/lib/browser/index.js +145 -0
  15. package/dist/lib/browser/index.js.map +1 -0
  16. package/dist/lib/env/index.d.ts +41 -0
  17. package/dist/lib/env/index.d.ts.map +1 -0
  18. package/dist/lib/env/index.js +125 -0
  19. package/dist/lib/env/index.js.map +1 -0
  20. package/dist/lib/tunnel/index.d.ts +38 -0
  21. package/dist/lib/tunnel/index.d.ts.map +1 -0
  22. package/dist/lib/tunnel/index.js +154 -0
  23. package/dist/lib/tunnel/index.js.map +1 -0
  24. package/dist/package.json +100 -0
  25. package/dist/src/cli/commands/info.d.ts +6 -0
  26. package/dist/src/cli/commands/info.d.ts.map +1 -0
  27. package/dist/src/cli/commands/info.js +32 -0
  28. package/dist/src/cli/commands/info.js.map +1 -0
  29. package/dist/src/cli/commands/mcp.d.ts +6 -0
  30. package/dist/src/cli/commands/mcp.d.ts.map +1 -0
  31. package/dist/src/cli/commands/mcp.js +45 -0
  32. package/dist/src/cli/commands/mcp.js.map +1 -0
  33. package/dist/src/cli/commands/setup.d.ts +6 -0
  34. package/dist/src/cli/commands/setup.d.ts.map +1 -0
  35. package/dist/src/cli/commands/setup.js +59 -0
  36. package/dist/src/cli/commands/setup.js.map +1 -0
  37. package/dist/src/cli/commands/test/index.d.ts +6 -0
  38. package/dist/src/cli/commands/test/index.d.ts.map +1 -0
  39. package/dist/src/cli/commands/test/index.js +15 -0
  40. package/dist/src/cli/commands/test/index.js.map +1 -0
  41. package/dist/src/cli/commands/test/init.d.ts +6 -0
  42. package/dist/src/cli/commands/test/init.d.ts.map +1 -0
  43. package/dist/src/cli/commands/test/init.js +64 -0
  44. package/dist/src/cli/commands/test/init.js.map +1 -0
  45. package/dist/src/cli/commands/test/list.d.ts +6 -0
  46. package/dist/src/cli/commands/test/list.d.ts.map +1 -0
  47. package/dist/src/cli/commands/test/list.js +70 -0
  48. package/dist/src/cli/commands/test/list.js.map +1 -0
  49. package/dist/src/cli/commands/test/run.d.ts +6 -0
  50. package/dist/src/cli/commands/test/run.d.ts.map +1 -0
  51. package/dist/src/cli/commands/test/run.js +95 -0
  52. package/dist/src/cli/commands/test/run.js.map +1 -0
  53. package/dist/src/cli/commands/test/validate.d.ts +6 -0
  54. package/dist/src/cli/commands/test/validate.d.ts.map +1 -0
  55. package/dist/src/cli/commands/test/validate.js +70 -0
  56. package/dist/src/cli/commands/test/validate.js.map +1 -0
  57. package/dist/src/cli/index.d.ts +6 -0
  58. package/dist/src/cli/index.d.ts.map +1 -0
  59. package/dist/src/cli/index.js +21 -0
  60. package/dist/src/cli/index.js.map +1 -0
  61. package/dist/src/cli/lib/config.d.ts +36 -0
  62. package/dist/src/cli/lib/config.d.ts.map +1 -0
  63. package/dist/src/cli/lib/config.js +89 -0
  64. package/dist/src/cli/lib/config.js.map +1 -0
  65. package/dist/src/cli/lib/loader.d.ts +49 -0
  66. package/dist/src/cli/lib/loader.d.ts.map +1 -0
  67. package/dist/src/cli/lib/loader.js +122 -0
  68. package/dist/src/cli/lib/loader.js.map +1 -0
  69. package/dist/src/cli/lib/output.d.ts +53 -0
  70. package/dist/src/cli/lib/output.d.ts.map +1 -0
  71. package/dist/src/cli/lib/output.js +133 -0
  72. package/dist/src/cli/lib/output.js.map +1 -0
  73. package/dist/src/cli/lib/runner.d.ts +23 -0
  74. package/dist/src/cli/lib/runner.d.ts.map +1 -0
  75. package/dist/src/cli/lib/runner.js +40 -0
  76. package/dist/src/cli/lib/runner.js.map +1 -0
  77. package/dist/src/http-server.d.ts +14 -0
  78. package/dist/src/http-server.d.ts.map +1 -0
  79. package/dist/src/http-server.js +145 -0
  80. package/dist/src/http-server.js.map +1 -0
  81. package/dist/src/index.d.ts +9 -0
  82. package/dist/src/index.d.ts.map +1 -0
  83. package/dist/src/index.js +21 -0
  84. package/dist/src/index.js.map +1 -0
  85. package/dist/src/server.d.ts +58 -0
  86. package/dist/src/server.d.ts.map +1 -0
  87. package/dist/src/server.js +2376 -0
  88. package/dist/src/server.js.map +1 -0
  89. package/dist/src/tunnel-mode.d.ts +13 -0
  90. package/dist/src/tunnel-mode.d.ts.map +1 -0
  91. package/dist/src/tunnel-mode.js +159 -0
  92. package/dist/src/tunnel-mode.js.map +1 -0
  93. package/dist/src/types/test-definition.d.ts +320 -0
  94. package/dist/src/types/test-definition.d.ts.map +1 -0
  95. package/dist/src/types/test-definition.js +11 -0
  96. package/dist/src/types/test-definition.js.map +1 -0
  97. package/dist/src/types.d.ts +209 -0
  98. package/dist/src/types.d.ts.map +1 -0
  99. package/dist/src/types.js +34 -0
  100. package/dist/src/types.js.map +1 -0
  101. package/dist/src/utils/package.d.ts +12 -0
  102. package/dist/src/utils/package.d.ts.map +1 -0
  103. package/dist/src/utils/package.js +36 -0
  104. package/dist/src/utils/package.js.map +1 -0
  105. package/dist/src/utils/summary.d.ts +45 -0
  106. package/dist/src/utils/summary.d.ts.map +1 -0
  107. package/dist/src/utils/summary.js +198 -0
  108. package/dist/src/utils/summary.js.map +1 -0
  109. package/lib/api/index.ts +977 -0
  110. package/lib/api/sse.ts +112 -0
  111. package/lib/browser/index.ts +181 -0
  112. package/lib/env/index.ts +156 -0
  113. package/lib/tunnel/index.test.ts +344 -0
  114. package/lib/tunnel/index.ts +197 -0
  115. package/lib/tunnel/integration.test.ts +98 -0
  116. package/package.json +100 -0
  117. package/server.json +16 -0
@@ -0,0 +1,344 @@
1
+ import { describe, it, expect, beforeEach, mock } from 'bun:test';
2
+ import { TunnelManager } from './index';
3
+
4
+ // Mock the localtunnel module
5
+ const mockTunnel = {
6
+ url: 'https://test-subdomain.lt.desplega.ai',
7
+ on: mock(() => {}),
8
+ close: mock(() => {}),
9
+ };
10
+
11
+ const mockLocaltunnel = mock(() => Promise.resolve(mockTunnel));
12
+
13
+ mock.module('@desplega.ai/localtunnel', () => ({
14
+ default: mockLocaltunnel,
15
+ }));
16
+
17
+ describe('TunnelManager', () => {
18
+ let tunnelManager: TunnelManager;
19
+
20
+ beforeEach(() => {
21
+ tunnelManager = new TunnelManager();
22
+ mockLocaltunnel.mockClear();
23
+ mockTunnel.on.mockClear();
24
+ mockTunnel.close.mockClear();
25
+ });
26
+
27
+ describe('generateDeterministicSubdomain (static)', () => {
28
+ it('should generate consistent subdomain for same API key and index', () => {
29
+ const apiKey = 'test-api-key-123';
30
+ const subdomain1 = TunnelManager.generateDeterministicSubdomain(apiKey, 0);
31
+ const subdomain2 = TunnelManager.generateDeterministicSubdomain(apiKey, 0);
32
+
33
+ expect(subdomain1).toBe(subdomain2);
34
+ });
35
+
36
+ it('should generate subdomain with correct format', () => {
37
+ const apiKey = 'test-api-key-123';
38
+ const subdomain = TunnelManager.generateDeterministicSubdomain(apiKey, 0);
39
+
40
+ expect(subdomain).toMatch(/^qa-use-[a-f0-9]{6}-\d$/);
41
+ });
42
+
43
+ it('should generate different subdomains for different indices', () => {
44
+ const apiKey = 'test-api-key-123';
45
+ const subdomain0 = TunnelManager.generateDeterministicSubdomain(apiKey, 0);
46
+ const subdomain1 = TunnelManager.generateDeterministicSubdomain(apiKey, 1);
47
+ const subdomain9 = TunnelManager.generateDeterministicSubdomain(apiKey, 9);
48
+
49
+ expect(subdomain0).not.toBe(subdomain1);
50
+ expect(subdomain0).not.toBe(subdomain9);
51
+ expect(subdomain1).not.toBe(subdomain9);
52
+ });
53
+
54
+ it('should generate different subdomains for different API keys', () => {
55
+ const subdomain1 = TunnelManager.generateDeterministicSubdomain('api-key-1', 0);
56
+ const subdomain2 = TunnelManager.generateDeterministicSubdomain('api-key-2', 0);
57
+
58
+ expect(subdomain1).not.toBe(subdomain2);
59
+ });
60
+
61
+ it('should clamp session index to valid range (0-9)', () => {
62
+ const apiKey = 'test-api-key';
63
+
64
+ // Test negative index
65
+ const subdomainNegative = TunnelManager.generateDeterministicSubdomain(apiKey, -5);
66
+ expect(subdomainNegative).toMatch(/^qa-use-[a-f0-9]{6}-0$/);
67
+
68
+ // Test index > 9
69
+ const subdomainLarge = TunnelManager.generateDeterministicSubdomain(apiKey, 15);
70
+ expect(subdomainLarge).toMatch(/^qa-use-[a-f0-9]{6}-9$/);
71
+
72
+ // Test valid indices
73
+ const subdomain0 = TunnelManager.generateDeterministicSubdomain(apiKey, 0);
74
+ const subdomain5 = TunnelManager.generateDeterministicSubdomain(apiKey, 5);
75
+ const subdomain9 = TunnelManager.generateDeterministicSubdomain(apiKey, 9);
76
+
77
+ expect(subdomain0).toMatch(/^qa-use-[a-f0-9]{6}-0$/);
78
+ expect(subdomain5).toMatch(/^qa-use-[a-f0-9]{6}-5$/);
79
+ expect(subdomain9).toMatch(/^qa-use-[a-f0-9]{6}-9$/);
80
+ });
81
+
82
+ it('should use first 6 characters of SHA-256 hash', () => {
83
+ // We can verify the subdomain contains exactly 6 hex characters for the hash part
84
+ const apiKey = 'test-api-key';
85
+ const subdomain = TunnelManager.generateDeterministicSubdomain(apiKey, 0);
86
+
87
+ const parts = subdomain.split('-');
88
+ expect(parts).toHaveLength(4); // qa, use, <hash>, <index>
89
+ expect(parts[2]).toHaveLength(6);
90
+ expect(parts[2]).toMatch(/^[a-f0-9]{6}$/);
91
+ });
92
+ });
93
+
94
+ describe('startTunnel', () => {
95
+ it('should create a tunnel', async () => {
96
+ const session = await tunnelManager.startTunnel(3000);
97
+
98
+ expect(mockLocaltunnel).toHaveBeenCalledTimes(1);
99
+ const callArgs = mockLocaltunnel.mock.calls[0][0];
100
+
101
+ expect(callArgs.port).toBe(3000);
102
+ expect(callArgs.host).toBe('https://lt.desplega.ai');
103
+ expect(callArgs.local_host).toBe('localhost');
104
+ expect(session.publicUrl).toBe(mockTunnel.url);
105
+ expect(session.localPort).toBe(3000);
106
+ expect(session.isActive).toBe(true);
107
+ });
108
+
109
+ it('should use custom subdomain when provided', async () => {
110
+ await tunnelManager.startTunnel(3000, { subdomain: 'my-custom-subdomain' });
111
+
112
+ const callArgs = mockLocaltunnel.mock.calls[0][0];
113
+ expect(callArgs.subdomain).toBe('my-custom-subdomain');
114
+ });
115
+
116
+ it('should generate subdomain when not provided', async () => {
117
+ await tunnelManager.startTunnel(3000);
118
+
119
+ const callArgs = mockLocaltunnel.mock.calls[0][0];
120
+ expect(callArgs.subdomain).toMatch(/^qa-use-\d{6}$/);
121
+ });
122
+
123
+ it('should generate deterministic subdomain when API key provided', async () => {
124
+ const apiKey = 'test-api-key-123';
125
+ await tunnelManager.startTunnel(3000, { apiKey, sessionIndex: 0 });
126
+
127
+ const callArgs = mockLocaltunnel.mock.calls[0][0];
128
+ expect(callArgs.subdomain).toMatch(/^qa-use-[a-f0-9]{6}-\d$/);
129
+ });
130
+
131
+ it('should generate same subdomain for same API key and index', async () => {
132
+ const apiKey = 'test-api-key-123';
133
+
134
+ await tunnelManager.startTunnel(3000, { apiKey, sessionIndex: 0 });
135
+ const firstCall = mockLocaltunnel.mock.calls[0][0];
136
+
137
+ // Stop tunnel to allow creating a new one
138
+ await tunnelManager.stopTunnel();
139
+ mockLocaltunnel.mockClear();
140
+
141
+ await tunnelManager.startTunnel(3000, { apiKey, sessionIndex: 0 });
142
+ const secondCall = mockLocaltunnel.mock.calls[0][0];
143
+
144
+ expect(firstCall.subdomain).toBe(secondCall.subdomain);
145
+ });
146
+
147
+ it('should generate different subdomains for different session indices', async () => {
148
+ const apiKey = 'test-api-key-123';
149
+
150
+ await tunnelManager.startTunnel(3000, { apiKey, sessionIndex: 0 });
151
+ const firstCall = mockLocaltunnel.mock.calls[0][0];
152
+
153
+ await tunnelManager.stopTunnel();
154
+ mockLocaltunnel.mockClear();
155
+
156
+ await tunnelManager.startTunnel(3000, { apiKey, sessionIndex: 1 });
157
+ const secondCall = mockLocaltunnel.mock.calls[0][0];
158
+
159
+ expect(firstCall.subdomain).not.toBe(secondCall.subdomain);
160
+ });
161
+
162
+ it('should generate different subdomains for different API keys', async () => {
163
+ await tunnelManager.startTunnel(3000, { apiKey: 'api-key-1', sessionIndex: 0 });
164
+ const firstCall = mockLocaltunnel.mock.calls[0][0];
165
+
166
+ await tunnelManager.stopTunnel();
167
+ mockLocaltunnel.mockClear();
168
+
169
+ await tunnelManager.startTunnel(3000, { apiKey: 'api-key-2', sessionIndex: 0 });
170
+ const secondCall = mockLocaltunnel.mock.calls[0][0];
171
+
172
+ expect(firstCall.subdomain).not.toBe(secondCall.subdomain);
173
+ });
174
+
175
+ it('should prefer custom subdomain over deterministic generation', async () => {
176
+ await tunnelManager.startTunnel(3000, {
177
+ subdomain: 'custom-domain',
178
+ apiKey: 'test-key',
179
+ sessionIndex: 0,
180
+ });
181
+
182
+ const callArgs = mockLocaltunnel.mock.calls[0][0];
183
+ expect(callArgs.subdomain).toBe('custom-domain');
184
+ });
185
+
186
+ it('should use custom local host when provided', async () => {
187
+ await tunnelManager.startTunnel(3000, { localHost: '127.0.0.1' });
188
+
189
+ const callArgs = mockLocaltunnel.mock.calls[0][0];
190
+ expect(callArgs.local_host).toBe('127.0.0.1');
191
+ });
192
+
193
+ it('should use TUNNEL_HOST environment variable when set', async () => {
194
+ const originalHost = process.env.TUNNEL_HOST;
195
+ process.env.TUNNEL_HOST = 'https://custom-tunnel.example.com';
196
+
197
+ await tunnelManager.startTunnel(3000);
198
+
199
+ const callArgs = mockLocaltunnel.mock.calls[0][0];
200
+ expect(callArgs.host).toBe('https://custom-tunnel.example.com');
201
+
202
+ // Restore original value
203
+ if (originalHost !== undefined) {
204
+ process.env.TUNNEL_HOST = originalHost;
205
+ } else {
206
+ delete process.env.TUNNEL_HOST;
207
+ }
208
+ });
209
+
210
+ it('should throw error if tunnel session already active', async () => {
211
+ await tunnelManager.startTunnel(3000);
212
+
213
+ await expect(tunnelManager.startTunnel(3001)).rejects.toThrow(
214
+ 'Tunnel session already active'
215
+ );
216
+ });
217
+
218
+ it('should register event handlers for tunnel', async () => {
219
+ await tunnelManager.startTunnel(3000);
220
+
221
+ expect(mockTunnel.on).toHaveBeenCalledWith('close', expect.any(Function));
222
+ expect(mockTunnel.on).toHaveBeenCalledWith('error', expect.any(Function));
223
+ });
224
+
225
+ it('should mark session as inactive on close event', async () => {
226
+ await tunnelManager.startTunnel(3000);
227
+
228
+ // Get the close handler
229
+ const closeHandler = mockTunnel.on.mock.calls.find((call: any) => call[0] === 'close')?.[1];
230
+
231
+ expect(closeHandler).toBeDefined();
232
+
233
+ // Simulate close event
234
+ if (closeHandler) {
235
+ closeHandler();
236
+ }
237
+
238
+ expect(tunnelManager.getSession()?.isActive).toBe(false);
239
+ });
240
+ });
241
+
242
+ describe('stopTunnel', () => {
243
+ it('should close the tunnel and clear session', async () => {
244
+ await tunnelManager.startTunnel(3000);
245
+ await tunnelManager.stopTunnel();
246
+
247
+ expect(mockTunnel.close).toHaveBeenCalledTimes(1);
248
+ expect(tunnelManager.getSession()).toBeNull();
249
+ });
250
+
251
+ it('should do nothing if no active session', async () => {
252
+ await tunnelManager.stopTunnel();
253
+
254
+ expect(mockTunnel.close).not.toHaveBeenCalled();
255
+ });
256
+
257
+ it('should handle errors gracefully during cleanup', async () => {
258
+ await tunnelManager.startTunnel(3000);
259
+ mockTunnel.close.mockImplementation(() => {
260
+ throw new Error('Close failed');
261
+ });
262
+
263
+ // Should not throw
264
+ await expect(tunnelManager.stopTunnel()).resolves.toBeUndefined();
265
+ });
266
+ });
267
+
268
+ describe('getSession', () => {
269
+ it('should return null when no session active', () => {
270
+ expect(tunnelManager.getSession()).toBeNull();
271
+ });
272
+
273
+ it('should return session when active', async () => {
274
+ await tunnelManager.startTunnel(3000);
275
+ const session = tunnelManager.getSession();
276
+
277
+ expect(session).not.toBeNull();
278
+ expect(session?.publicUrl).toBe(mockTunnel.url);
279
+ });
280
+ });
281
+
282
+ describe('isActive', () => {
283
+ it('should return false when no session', () => {
284
+ expect(tunnelManager.isActive()).toBe(false);
285
+ });
286
+
287
+ it('should return true when session is active', async () => {
288
+ await tunnelManager.startTunnel(3000);
289
+ expect(tunnelManager.isActive()).toBe(true);
290
+ });
291
+
292
+ it('should return false when session is inactive', async () => {
293
+ await tunnelManager.startTunnel(3000);
294
+
295
+ // Simulate close event
296
+ const closeHandler = mockTunnel.on.mock.calls.find((call: any) => call[0] === 'close')?.[1];
297
+
298
+ if (closeHandler) {
299
+ closeHandler();
300
+ }
301
+
302
+ expect(tunnelManager.isActive()).toBe(false);
303
+ });
304
+ });
305
+
306
+ describe('getPublicUrl', () => {
307
+ it('should return null when no session', () => {
308
+ expect(tunnelManager.getPublicUrl()).toBeNull();
309
+ });
310
+
311
+ it('should return public URL when session active', async () => {
312
+ await tunnelManager.startTunnel(3000);
313
+ expect(tunnelManager.getPublicUrl()).toBe('https://test-subdomain.lt.desplega.ai');
314
+ });
315
+ });
316
+
317
+ describe('getWebSocketUrl', () => {
318
+ it('should return null when no session', () => {
319
+ expect(tunnelManager.getWebSocketUrl('ws://localhost:9222/ws')).toBeNull();
320
+ });
321
+
322
+ it('should convert https URL to wss and append WebSocket path', async () => {
323
+ await tunnelManager.startTunnel(3000);
324
+
325
+ const wsUrl = tunnelManager.getWebSocketUrl('ws://localhost:9222/ws/browser/abc123');
326
+ expect(wsUrl).toBe('wss://test-subdomain.lt.desplega.ai/ws/browser/abc123');
327
+ });
328
+
329
+ it('should handle http to ws conversion', async () => {
330
+ mockTunnel.url = 'http://test.lt.desplega.ai';
331
+ await tunnelManager.startTunnel(3000);
332
+
333
+ const wsUrl = tunnelManager.getWebSocketUrl('ws://localhost:9222/ws');
334
+ expect(wsUrl).toBe('ws://test.lt.desplega.ai/ws');
335
+ });
336
+
337
+ it('should return null for invalid WebSocket URL', async () => {
338
+ await tunnelManager.startTunnel(3000);
339
+
340
+ const wsUrl = tunnelManager.getWebSocketUrl('invalid-url');
341
+ expect(wsUrl).toBeNull();
342
+ });
343
+ });
344
+ });
@@ -0,0 +1,197 @@
1
+ import localtunnel from '@desplega.ai/localtunnel';
2
+ import { URL } from 'url';
3
+ import https from 'https';
4
+ import crypto from 'crypto';
5
+ import { getEnv } from '../env/index.js';
6
+
7
+ export interface TunnelSession {
8
+ tunnel: localtunnel.Tunnel;
9
+ publicUrl: string;
10
+ localPort: number;
11
+ isActive: boolean;
12
+ host: string;
13
+ region: string;
14
+ }
15
+
16
+ export interface TunnelOptions {
17
+ subdomain?: string;
18
+ localHost?: string;
19
+ apiKey?: string;
20
+ sessionIndex?: number;
21
+ }
22
+
23
+ export class TunnelManager {
24
+ private session: TunnelSession | null = null;
25
+ private readonly defaultRegion: string = 'auto';
26
+
27
+ /**
28
+ * Generate a deterministic subdomain based on API key and session index
29
+ * @param apiKey - The API key to hash
30
+ * @param sessionIndex - Index from 0-9 for concurrent sessions
31
+ * @returns Deterministic subdomain in format: qa-use-{hash}-{index}
32
+ */
33
+ static generateDeterministicSubdomain(apiKey: string, sessionIndex: number): string {
34
+ // Hash the API key using SHA-256
35
+ const hash = crypto.createHash('sha256').update(apiKey).digest('hex');
36
+
37
+ // Take first 6 characters of hash for brevity
38
+ const shortHash = hash.substring(0, 6);
39
+
40
+ // Ensure sessionIndex is within valid range (0-9)
41
+ const validIndex = Math.max(0, Math.min(9, sessionIndex));
42
+
43
+ return `qa-use-${shortHash}-${validIndex}`;
44
+ }
45
+
46
+ async startTunnel(port: number, options: TunnelOptions = {}): Promise<TunnelSession> {
47
+ if (this.session) {
48
+ throw new Error('Tunnel session already active');
49
+ }
50
+
51
+ const region = getEnv('QA_USE_REGION') || this.defaultRegion;
52
+ let host = getEnv('TUNNEL_HOST');
53
+
54
+ if (!host) {
55
+ // If no manual override, determine host based on region
56
+ if (region === 'us') {
57
+ host = 'https://lt.us.desplega.ai';
58
+ } else {
59
+ // Default for 'auto' or unset
60
+ host = 'https://lt.desplega.ai';
61
+ }
62
+ }
63
+
64
+ // Determine subdomain: custom > deterministic > random
65
+ let subdomain = options.subdomain;
66
+ if (!subdomain && options.apiKey !== undefined && options.sessionIndex !== undefined) {
67
+ // Use deterministic subdomain based on API key and session index
68
+ subdomain = TunnelManager.generateDeterministicSubdomain(
69
+ options.apiKey,
70
+ options.sessionIndex
71
+ );
72
+ console.log(`Using deterministic subdomain: ${subdomain}`);
73
+ } else if (!subdomain) {
74
+ // Fallback to timestamp-based random subdomain
75
+ subdomain = `qa-use-${Date.now().toString().slice(-6)}`;
76
+ console.log(`Using random subdomain: ${subdomain}`);
77
+ }
78
+
79
+ console.log(`Starting tunnel on port ${port} with host ${host} in region ${region}`);
80
+
81
+ const tunnel = await localtunnel({
82
+ port,
83
+ host,
84
+ subdomain,
85
+ local_host: options.localHost || 'localhost',
86
+ auth: true,
87
+ });
88
+
89
+ console.log(`Tunnel started at ${tunnel.url}`);
90
+
91
+ this.session = {
92
+ tunnel,
93
+ publicUrl: tunnel.url,
94
+ localPort: port,
95
+ isActive: true,
96
+ host,
97
+ region,
98
+ };
99
+
100
+ // Handle tunnel events
101
+ tunnel.on('close', () => {
102
+ if (this.session) {
103
+ this.session.isActive = false;
104
+ }
105
+ });
106
+
107
+ tunnel.on('error', (err: Error) => {
108
+ if (this.session) {
109
+ this.session.isActive = false;
110
+ }
111
+ throw err;
112
+ });
113
+
114
+ return this.session;
115
+ }
116
+
117
+ async stopTunnel(): Promise<void> {
118
+ if (!this.session) {
119
+ return;
120
+ }
121
+
122
+ const session = this.session;
123
+ this.session = null;
124
+
125
+ try {
126
+ session.tunnel.close();
127
+ } catch (error) {
128
+ // Silently handle cleanup errors
129
+ }
130
+ }
131
+
132
+ getSession(): TunnelSession | null {
133
+ return this.session;
134
+ }
135
+
136
+ isActive(): boolean {
137
+ return this.session?.isActive ?? false;
138
+ }
139
+
140
+ /**
141
+ * Check if tunnel is actually alive by pinging the public URL
142
+ */
143
+ async checkHealth(): Promise<boolean> {
144
+ if (!this.session) return false;
145
+
146
+ try {
147
+ const controller = new AbortController();
148
+ const timeout = setTimeout(() => controller.abort(), 5000);
149
+
150
+ const response = await fetch(this.session.publicUrl, {
151
+ method: 'HEAD',
152
+ signal: controller.signal,
153
+ });
154
+
155
+ clearTimeout(timeout);
156
+ // 426 = Upgrade Required (expected for WebSocket endpoint)
157
+ return response.ok || response.status === 426;
158
+ } catch (error) {
159
+ this.session.isActive = false;
160
+ return false;
161
+ }
162
+ }
163
+
164
+ getPublicUrl(): string | null {
165
+ return this.session?.publicUrl ?? null;
166
+ }
167
+
168
+ async getPublicIP(): Promise<string> {
169
+ return new Promise((resolve, reject) => {
170
+ https
171
+ .get('https://api.ipify.org', (res) => {
172
+ let data = '';
173
+ res.on('data', (chunk) => (data += chunk));
174
+ res.on('end', () => resolve(data.trim()));
175
+ })
176
+ .on('error', (err) => {
177
+ reject(err);
178
+ });
179
+ });
180
+ }
181
+
182
+ getWebSocketUrl(originalWsEndpoint: string): string | null {
183
+ if (!this.session) return null;
184
+
185
+ try {
186
+ const localWsUrl = new URL(originalWsEndpoint);
187
+ const wsPath = localWsUrl.pathname;
188
+
189
+ // Convert HTTP/HTTPS URLs to WebSocket URLs (ws/wss)
190
+ return (
191
+ this.session.publicUrl.replace('https://', 'wss://').replace('http://', 'ws://') + wsPath
192
+ );
193
+ } catch (error) {
194
+ return null;
195
+ }
196
+ }
197
+ }
@@ -0,0 +1,98 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from 'bun:test';
2
+ import { TunnelManager } from './index';
3
+ import http from 'http';
4
+
5
+ describe('TunnelManager Integration Tests', () => {
6
+ let tunnelManager: TunnelManager;
7
+ let testServer: http.Server;
8
+ let testPort: number;
9
+
10
+ beforeEach(async () => {
11
+ tunnelManager = new TunnelManager();
12
+
13
+ // Create a simple test HTTP server
14
+ return new Promise<void>((resolve) => {
15
+ testServer = http.createServer((req, res) => {
16
+ res.writeHead(200, { 'Content-Type': 'text/plain' });
17
+ res.end('Hello from test server');
18
+ });
19
+
20
+ testServer.listen(0, () => {
21
+ const addr = testServer.address();
22
+ if (addr && typeof addr === 'object') {
23
+ testPort = addr.port;
24
+ }
25
+ resolve();
26
+ });
27
+ });
28
+ });
29
+
30
+ afterEach(async () => {
31
+ await tunnelManager.stopTunnel();
32
+
33
+ return new Promise<void>((resolve, reject) => {
34
+ if (testServer) {
35
+ testServer.close((err) => {
36
+ if (err) reject(err);
37
+ else resolve();
38
+ });
39
+ } else {
40
+ resolve();
41
+ }
42
+ });
43
+ });
44
+
45
+ it('should create a real tunnel with authentication', async () => {
46
+ const session = await tunnelManager.startTunnel(testPort);
47
+
48
+ expect(session).toBeDefined();
49
+ expect(session.publicUrl).toMatch(/^https?:\/\/.+\.lt\.desplega\.ai$/);
50
+ expect(session.localPort).toBe(testPort);
51
+ expect(session.isActive).toBe(true);
52
+ expect(tunnelManager.isActive()).toBe(true);
53
+ }, 10000); // 10 second timeout for network operations
54
+
55
+ it('should create tunnel with custom subdomain', async () => {
56
+ const subdomain = `qa-use-test-${Date.now().toString().slice(-6)}`;
57
+ const session = await tunnelManager.startTunnel(testPort, { subdomain });
58
+
59
+ // Note: subdomain may not be honored depending on availability
60
+ // Just verify tunnel was created successfully
61
+ expect(session.publicUrl).toMatch(/^https?:\/\/.+\.lt\.desplega\.ai$/);
62
+ expect(session.isActive).toBe(true);
63
+ }, 10000);
64
+
65
+ it('should generate WebSocket URL from tunnel', async () => {
66
+ await tunnelManager.startTunnel(testPort);
67
+
68
+ const wsUrl = tunnelManager.getWebSocketUrl('ws://localhost:9222/ws/browser/abc123');
69
+
70
+ expect(wsUrl).toBeDefined();
71
+ expect(wsUrl).toMatch(/^wss?:\/\/.+\.lt\.desplega\.ai\/ws\/browser\/abc123$/);
72
+ }, 10000);
73
+
74
+ it('should properly close tunnel', async () => {
75
+ await tunnelManager.startTunnel(testPort);
76
+
77
+ expect(tunnelManager.isActive()).toBe(true);
78
+
79
+ await tunnelManager.stopTunnel();
80
+
81
+ expect(tunnelManager.isActive()).toBe(false);
82
+ expect(tunnelManager.getSession()).toBeNull();
83
+ }, 10000);
84
+
85
+ it('should handle multiple start/stop cycles', async () => {
86
+ // First cycle
87
+ await tunnelManager.startTunnel(testPort);
88
+ expect(tunnelManager.isActive()).toBe(true);
89
+ await tunnelManager.stopTunnel();
90
+ expect(tunnelManager.isActive()).toBe(false);
91
+
92
+ // Second cycle
93
+ await tunnelManager.startTunnel(testPort);
94
+ expect(tunnelManager.isActive()).toBe(true);
95
+ await tunnelManager.stopTunnel();
96
+ expect(tunnelManager.isActive()).toBe(false);
97
+ }, 20000);
98
+ });