@shogo-ai/worker 1.8.10 → 1.8.12
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/package.json +1 -1
- package/src/lib/__tests__/cloud-login-coverage.test.ts +210 -0
- package/src/lib/__tests__/git-cloner.test.ts +130 -6
- package/src/lib/__tests__/runtime-manager-coverage-gaps.test.ts +768 -0
- package/src/lib/__tests__/runtime-manager-wait-for-health.test.ts +66 -48
- package/src/lib/__tests__/tunnel-coverage.test.ts +1094 -0
- package/src/lib/runtime-manager.ts +112 -62
- package/src/lib/tunnel.ts +0 -4
|
@@ -0,0 +1,1094 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
// Copyright (C) 2026 Shogo Technologies, Inc.
|
|
3
|
+
/**
|
|
4
|
+
* Comprehensive coverage gaps for WorkerTunnel (tunnel.ts).
|
|
5
|
+
*
|
|
6
|
+
* Covered clusters:
|
|
7
|
+
* L117-121 TunnelWebSocketHeaderSupportError constructor
|
|
8
|
+
* L186-209 start() / stop() lifecycle
|
|
9
|
+
* L212-215 isConnected()
|
|
10
|
+
* L220-228 getCloudUrl(), getWsBaseUrl() branches
|
|
11
|
+
* L231-232 buildWsUrl()
|
|
12
|
+
* L235-256 supportsWebSocketConstructorHeaders(), createTunnelWebSocket(), getReconnectDelayMs()
|
|
13
|
+
* L259-286 collectMetadata()
|
|
14
|
+
* L289-323 sendHeartbeat()
|
|
15
|
+
* L327-400 scheduleNextPoll() + heartbeatLoop() (all branches)
|
|
16
|
+
* L539-554 resetWsIdleTimer() callback, startWsHeartbeat() callback
|
|
17
|
+
* L558-659 connectWs() + WS event handlers (onopen/onmessage/onclose/onerror)
|
|
18
|
+
* L682-700 _testing() proxies + getters/setters
|
|
19
|
+
*/
|
|
20
|
+
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
|
|
21
|
+
import {
|
|
22
|
+
WorkerTunnel,
|
|
23
|
+
TunnelWebSocketHeaderSupportError,
|
|
24
|
+
TUNNEL_PROTOCOL_VERSION,
|
|
25
|
+
type RuntimeResolver,
|
|
26
|
+
type WorkerTunnelOptions,
|
|
27
|
+
} from '../tunnel.ts';
|
|
28
|
+
|
|
29
|
+
// ─── FakeWebSocket ───────────────────────────────────────────────────────────
|
|
30
|
+
|
|
31
|
+
class FakeWebSocket {
|
|
32
|
+
static readonly CONNECTING = 0;
|
|
33
|
+
static readonly OPEN = 1;
|
|
34
|
+
static readonly CLOSING = 2;
|
|
35
|
+
static readonly CLOSED = 3;
|
|
36
|
+
|
|
37
|
+
readyState = FakeWebSocket.OPEN;
|
|
38
|
+
sent: string[] = [];
|
|
39
|
+
closedWith: { code: number; reason?: string } | null = null;
|
|
40
|
+
|
|
41
|
+
onopen: (() => void) | null = null;
|
|
42
|
+
onmessage: ((e: { data: string }) => void) | null = null;
|
|
43
|
+
onclose: ((e: { code: number; reason?: string }) => void) | null = null;
|
|
44
|
+
onerror: ((e: { message?: string }) => void) | null = null;
|
|
45
|
+
|
|
46
|
+
constructor(public url: string = '', public init: unknown = undefined) {}
|
|
47
|
+
|
|
48
|
+
send(msg: string) { this.sent.push(msg); }
|
|
49
|
+
close(code = 1000, reason = '') {
|
|
50
|
+
this.closedWith = { code, reason };
|
|
51
|
+
this.readyState = FakeWebSocket.CLOSED;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Test helpers
|
|
55
|
+
triggerOpen() { this.onopen?.(); }
|
|
56
|
+
triggerMessage(data: string | object) {
|
|
57
|
+
const str = typeof data === 'string' ? data : JSON.stringify(data);
|
|
58
|
+
this.onmessage?.({ data: str });
|
|
59
|
+
}
|
|
60
|
+
triggerClose(code = 1000, reason = '') {
|
|
61
|
+
this.readyState = FakeWebSocket.CLOSED;
|
|
62
|
+
this.onclose?.({ code, reason });
|
|
63
|
+
}
|
|
64
|
+
triggerError(message = 'ws error') { this.onerror?.({ message }); }
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Replace globalThis.WebSocket with a factory that captures the last created socket.
|
|
68
|
+
function installFakeWsFactory(): { last: () => FakeWebSocket | null } {
|
|
69
|
+
let lastSocket: FakeWebSocket | null = null;
|
|
70
|
+
function FakeWSCtor(url: string, init?: unknown) {
|
|
71
|
+
const ws = new FakeWebSocket(url, init);
|
|
72
|
+
lastSocket = ws;
|
|
73
|
+
return ws;
|
|
74
|
+
}
|
|
75
|
+
FakeWSCtor.OPEN = FakeWebSocket.OPEN;
|
|
76
|
+
FakeWSCtor.CONNECTING = FakeWebSocket.CONNECTING;
|
|
77
|
+
FakeWSCtor.CLOSING = FakeWebSocket.CLOSING;
|
|
78
|
+
FakeWSCtor.CLOSED = FakeWebSocket.CLOSED;
|
|
79
|
+
(globalThis as any).WebSocket = FakeWSCtor;
|
|
80
|
+
return { last: () => lastSocket };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
|
84
|
+
|
|
85
|
+
function makeResolver(overrides: Partial<RuntimeResolver> = {}): RuntimeResolver {
|
|
86
|
+
return {
|
|
87
|
+
resolveLocalUrl: async () => 'http://localhost:3000/agent/x',
|
|
88
|
+
deriveRuntimeToken: () => 'tok-abc',
|
|
89
|
+
getActiveProjects: () => ['proj-1'],
|
|
90
|
+
status: () => ({ status: 'running', agentPort: 3000 }),
|
|
91
|
+
...overrides,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const silentLogger = { log: () => {}, warn: () => {}, error: () => {} };
|
|
96
|
+
|
|
97
|
+
const logCapture = () => {
|
|
98
|
+
const logs: string[] = [];
|
|
99
|
+
const warns: string[] = [];
|
|
100
|
+
const errors: string[] = [];
|
|
101
|
+
return {
|
|
102
|
+
logger: {
|
|
103
|
+
log: (...a: unknown[]) => { logs.push(a.join(' ')); },
|
|
104
|
+
warn: (...a: unknown[]) => { warns.push(a.join(' ')); },
|
|
105
|
+
error: (...a: unknown[]) => { errors.push(a.join(' ')); },
|
|
106
|
+
},
|
|
107
|
+
logs, warns, errors,
|
|
108
|
+
};
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
function makeTunnel(overrides: Partial<WorkerTunnelOptions> = {}) {
|
|
112
|
+
return new WorkerTunnel({
|
|
113
|
+
apiKey: 'shogo_sk_test',
|
|
114
|
+
cloudUrl: 'https://api.shogo.ai',
|
|
115
|
+
resolver: makeResolver(),
|
|
116
|
+
logger: silentLogger,
|
|
117
|
+
...overrides,
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function fakeHeartbeatResp(body: object = { nextPollIn: 30, wsRequested: false }) {
|
|
122
|
+
return Promise.resolve(new Response(JSON.stringify(body), { status: 200 }));
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const origFetch = global.fetch;
|
|
126
|
+
const origWebSocket = (globalThis as any).WebSocket;
|
|
127
|
+
|
|
128
|
+
afterEach(() => {
|
|
129
|
+
global.fetch = origFetch;
|
|
130
|
+
(globalThis as any).WebSocket = origWebSocket;
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
// ─── TunnelWebSocketHeaderSupportError (L117-122) ───────────────────────────
|
|
134
|
+
|
|
135
|
+
describe('TunnelWebSocketHeaderSupportError (L117-122)', () => {
|
|
136
|
+
it('constructor sets message and code', () => {
|
|
137
|
+
const err = new TunnelWebSocketHeaderSupportError();
|
|
138
|
+
expect(err.code).toBe('TUNNEL_WS_HEADERS_UNSUPPORTED');
|
|
139
|
+
expect(err.name).toBe('TunnelWebSocketHeaderSupportError');
|
|
140
|
+
expect(err.message).toContain('Bun WebSocket');
|
|
141
|
+
expect(err).toBeInstanceOf(Error);
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
// ─── start() / stop() (L186-209) ────────────────────────────────────────────
|
|
146
|
+
|
|
147
|
+
describe('start() (L186-195)', () => {
|
|
148
|
+
it('with no apiKey: logs and returns without starting loop', () => {
|
|
149
|
+
const { logger, logs } = logCapture();
|
|
150
|
+
const t = makeTunnel({ apiKey: '', logger });
|
|
151
|
+
t.start();
|
|
152
|
+
expect(logs.some(l => l.includes('No API key'))).toBe(true);
|
|
153
|
+
expect(t._testing().stopped).toBe(false); // was never set to false since we returned early
|
|
154
|
+
t.stop();
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it('with apiKey: sets state and kicks off heartbeatLoop', async () => {
|
|
158
|
+
global.fetch = () => fakeHeartbeatResp() as any;
|
|
159
|
+
const t = makeTunnel();
|
|
160
|
+
t.start();
|
|
161
|
+
expect(t._testing().stopped).toBe(false);
|
|
162
|
+
expect(t._testing().wsReconnectAttempt).toBe(0);
|
|
163
|
+
t.stop();
|
|
164
|
+
// allow the kicked-off heartbeatLoop promise to settle so no unhandled rejection
|
|
165
|
+
await Promise.resolve();
|
|
166
|
+
});
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
describe('stop() (L198-209)', () => {
|
|
170
|
+
it('sets stopped=true and logs', () => {
|
|
171
|
+
const { logger, logs } = logCapture();
|
|
172
|
+
const t = makeTunnel({ logger });
|
|
173
|
+
t.stop();
|
|
174
|
+
expect(t._testing().stopped).toBe(true);
|
|
175
|
+
expect(logs.some(l => l.includes('Tunnel stopped'))).toBe(true);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it('clears pollTimer when set', async () => {
|
|
179
|
+
global.fetch = () => fakeHeartbeatResp() as any;
|
|
180
|
+
const t = makeTunnel();
|
|
181
|
+
// arm a pollTimer
|
|
182
|
+
await t._testing().heartbeatLoop();
|
|
183
|
+
// pollTimer should now be set; stop should clear it
|
|
184
|
+
t.stop();
|
|
185
|
+
expect(t._testing().stopped).toBe(true);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it('cleanupWs() via stop() nulls ws (close-in-stop dead code removed)', () => {
|
|
189
|
+
// stop() calls cleanupWs() which sets this.ws = null.
|
|
190
|
+
// The old `if (this.ws) { this.ws.close() }` block after cleanupWs was
|
|
191
|
+
// dead code (cleanupWs already nulled ws) and was deleted from source.
|
|
192
|
+
const t = makeTunnel();
|
|
193
|
+
const fake = new FakeWebSocket();
|
|
194
|
+
t._testing().installFakeWs(fake as unknown as WebSocket);
|
|
195
|
+
t.stop();
|
|
196
|
+
expect(t._testing().ws).toBeNull(); // cleanupWs() nulled it
|
|
197
|
+
expect(fake.closedWith).toBeNull(); // .close() was never called (dead code removed)
|
|
198
|
+
});
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
// ─── isConnected() (L212-215) ───────────────────────────────────────────────
|
|
202
|
+
|
|
203
|
+
describe('isConnected() (L212-215)', () => {
|
|
204
|
+
it('returns true when ws readyState is OPEN', () => {
|
|
205
|
+
const t = makeTunnel();
|
|
206
|
+
const fake = new FakeWebSocket();
|
|
207
|
+
fake.readyState = FakeWebSocket.OPEN;
|
|
208
|
+
t._testing().installFakeWs(fake as unknown as WebSocket);
|
|
209
|
+
expect(t.isConnected()).toBe(true);
|
|
210
|
+
t.stop();
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it('returns false when stopped', () => {
|
|
214
|
+
const t = makeTunnel();
|
|
215
|
+
t.stop();
|
|
216
|
+
expect(t.isConnected()).toBe(false);
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it('returns false when no apiKey', () => {
|
|
220
|
+
const t = makeTunnel({ apiKey: '' });
|
|
221
|
+
expect(t.isConnected()).toBe(false);
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
it('returns false when lastHeartbeatError is non-null (via heartbeat failure)', async () => {
|
|
225
|
+
global.fetch = () => Promise.reject(new Error('connection refused'));
|
|
226
|
+
const t = makeTunnel();
|
|
227
|
+
await t._testing().heartbeatLoop();
|
|
228
|
+
expect(t.isConnected()).toBe(false);
|
|
229
|
+
t.stop();
|
|
230
|
+
});
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
// ─── getCloudUrl() (L220-221) ───────────────────────────────────────────────
|
|
234
|
+
|
|
235
|
+
describe('getCloudUrl() (L220-221)', () => {
|
|
236
|
+
it('strips trailing slash', () => {
|
|
237
|
+
const t = makeTunnel({ cloudUrl: 'https://api.shogo.ai/' });
|
|
238
|
+
expect(t._testing().getCloudUrl()).toBe('https://api.shogo.ai');
|
|
239
|
+
});
|
|
240
|
+
it('no-ops when no trailing slash', () => {
|
|
241
|
+
const t = makeTunnel({ cloudUrl: 'https://api.shogo.ai' });
|
|
242
|
+
expect(t._testing().getCloudUrl()).toBe('https://api.shogo.ai');
|
|
243
|
+
});
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
// ─── getWsBaseUrl() (L224-228) ──────────────────────────────────────────────
|
|
247
|
+
|
|
248
|
+
describe('getWsBaseUrl() (L224-228)', () => {
|
|
249
|
+
it('uses wsUrlOverride when set', () => {
|
|
250
|
+
const t = makeTunnel({ wsUrlOverride: 'wss://ws.shogo.ai/' });
|
|
251
|
+
expect(t._testing().getWsBaseUrl()).toBe('wss://ws.shogo.ai');
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
it('uses SHOGO_TUNNEL_WS_URL env var', () => {
|
|
255
|
+
const origEnv = process.env.SHOGO_TUNNEL_WS_URL;
|
|
256
|
+
process.env.SHOGO_TUNNEL_WS_URL = 'wss://env-ws.shogo.ai';
|
|
257
|
+
try {
|
|
258
|
+
const t = makeTunnel();
|
|
259
|
+
expect(t._testing().getWsBaseUrl()).toBe('wss://env-ws.shogo.ai');
|
|
260
|
+
} finally {
|
|
261
|
+
if (origEnv === undefined) delete process.env.SHOGO_TUNNEL_WS_URL;
|
|
262
|
+
else process.env.SHOGO_TUNNEL_WS_URL = origEnv;
|
|
263
|
+
}
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
it('uses serverPublishedWsUrl when set (L227)', () => {
|
|
267
|
+
const t = makeTunnel();
|
|
268
|
+
t._testing().serverPublishedWsUrl = 'wss://published.shogo.ai/';
|
|
269
|
+
expect(t._testing().getWsBaseUrl()).toBe('wss://published.shogo.ai');
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
it('falls back to converting cloudUrl http → ws (L228)', () => {
|
|
273
|
+
const t = makeTunnel({ cloudUrl: 'https://api.shogo.ai' });
|
|
274
|
+
expect(t._testing().getWsBaseUrl()).toBe('wss://api.shogo.ai');
|
|
275
|
+
});
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
// ─── buildWsUrl() (L231-232) ─────────────────────────────────────────────────
|
|
279
|
+
|
|
280
|
+
describe('buildWsUrl() (L231-232)', () => {
|
|
281
|
+
it('appends /api/instances/ws to the WS base URL', () => {
|
|
282
|
+
const t = makeTunnel({ cloudUrl: 'https://api.shogo.ai' });
|
|
283
|
+
expect(t._testing().buildWsUrl()).toBe('wss://api.shogo.ai/api/instances/ws');
|
|
284
|
+
});
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
// ─── supportsWebSocketConstructorHeaders() (L235-238) ───────────────────────
|
|
288
|
+
|
|
289
|
+
describe('supportsWebSocketConstructorHeaders() (L235-238)', () => {
|
|
290
|
+
it('returns true when Bun global is present', () => {
|
|
291
|
+
const t = makeTunnel();
|
|
292
|
+
expect(t._testing().supportsWebSocketConstructorHeaders({ Bun: {} } as any)).toBe(true);
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
it('returns true when process.versions.bun is set', () => {
|
|
296
|
+
const t = makeTunnel();
|
|
297
|
+
expect(t._testing().supportsWebSocketConstructorHeaders({ process: { versions: { bun: '1.0' } } } as any)).toBe(true);
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
it('returns false when neither Bun nor bun version present', () => {
|
|
301
|
+
const t = makeTunnel();
|
|
302
|
+
expect(t._testing().supportsWebSocketConstructorHeaders({} as any)).toBe(false);
|
|
303
|
+
});
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
// ─── createTunnelWebSocket() (L241-250) ──────────────────────────────────────
|
|
307
|
+
|
|
308
|
+
describe('createTunnelWebSocket() (L241-250)', () => {
|
|
309
|
+
it('throws TunnelWebSocketHeaderSupportError on non-Bun runtime', () => {
|
|
310
|
+
const t = makeTunnel();
|
|
311
|
+
expect(() =>
|
|
312
|
+
t._testing().createTunnelWebSocket('wss://x', { headers: {} }, {} as any)
|
|
313
|
+
).toThrow(TunnelWebSocketHeaderSupportError);
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
it('calls new WebSocket(url, init) on Bun runtime', () => {
|
|
317
|
+
const factory = installFakeWsFactory();
|
|
318
|
+
const t = makeTunnel();
|
|
319
|
+
const result = t._testing().createTunnelWebSocket(
|
|
320
|
+
'wss://x/ws',
|
|
321
|
+
{ headers: { Authorization: 'Bearer abc' } },
|
|
322
|
+
{ Bun: {} } as any,
|
|
323
|
+
);
|
|
324
|
+
expect(factory.last()).not.toBeNull();
|
|
325
|
+
expect((result as unknown as FakeWebSocket).url).toBe('wss://x/ws');
|
|
326
|
+
});
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
// ─── getReconnectDelayMs() (L253-256) ─────────────────────────────────────────
|
|
330
|
+
|
|
331
|
+
describe('getReconnectDelayMs() (L253-256)', () => {
|
|
332
|
+
it('returns a value >= BACKOFF_BASE_MS on first attempt', () => {
|
|
333
|
+
const t = makeTunnel();
|
|
334
|
+
const delay = t._testing().getReconnectDelayMs();
|
|
335
|
+
expect(delay).toBeGreaterThanOrEqual(t._testing().BACKOFF_BASE_MS);
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
it('caps at BACKOFF_MAX_MS after many attempts', () => {
|
|
339
|
+
const t = makeTunnel();
|
|
340
|
+
t._testing().wsReconnectAttempt = 20;
|
|
341
|
+
const delay = t._testing().getReconnectDelayMs();
|
|
342
|
+
expect(delay).toBeLessThanOrEqual(t._testing().BACKOFF_MAX_MS * 1.2); // 20% jitter ceiling
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
it('increases with wsReconnectAttempt', () => {
|
|
346
|
+
const t = makeTunnel();
|
|
347
|
+
t._testing().wsReconnectAttempt = 0;
|
|
348
|
+
const d0 = t._testing().getReconnectDelayMs();
|
|
349
|
+
t._testing().wsReconnectAttempt = 5;
|
|
350
|
+
const d5 = t._testing().getReconnectDelayMs();
|
|
351
|
+
expect(d5).toBeGreaterThan(d0);
|
|
352
|
+
});
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
// ─── collectMetadata() (via sendHeartbeat) (L259-286) ────────────────────────
|
|
356
|
+
|
|
357
|
+
describe('collectMetadata() (L259-286)', () => {
|
|
358
|
+
it('includes expected fields in heartbeat payload', async () => {
|
|
359
|
+
let captured: any = null;
|
|
360
|
+
global.fetch = ((_url: string, init?: RequestInit) => {
|
|
361
|
+
captured = JSON.parse(init?.body as string ?? '{}');
|
|
362
|
+
return fakeHeartbeatResp() as any;
|
|
363
|
+
}) as any;
|
|
364
|
+
const t = makeTunnel();
|
|
365
|
+
await t._testing().sendHeartbeat();
|
|
366
|
+
t.stop();
|
|
367
|
+
expect(captured).not.toBeNull();
|
|
368
|
+
expect(captured.hostname).toBeDefined();
|
|
369
|
+
expect(captured.metadata.protocolVersion).toBe(TUNNEL_PROTOCOL_VERSION);
|
|
370
|
+
expect(captured.metadata.activeProjects).toBe(1);
|
|
371
|
+
expect(Array.isArray(captured.metadata.projects)).toBe(true);
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
it('handles resolver.getActiveProjects() throwing', async () => {
|
|
375
|
+
let captured: any = null;
|
|
376
|
+
global.fetch = ((_url: string, init?: RequestInit) => {
|
|
377
|
+
captured = JSON.parse(init?.body as string ?? '{}');
|
|
378
|
+
return fakeHeartbeatResp() as any;
|
|
379
|
+
}) as any;
|
|
380
|
+
const t = makeTunnel({
|
|
381
|
+
resolver: makeResolver({ getActiveProjects: () => { throw new Error('boom'); } }),
|
|
382
|
+
});
|
|
383
|
+
await t._testing().sendHeartbeat();
|
|
384
|
+
t.stop();
|
|
385
|
+
expect(captured.metadata.activeProjects).toBe(0);
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
it('reports tunnelStatus as connected when ws is OPEN', async () => {
|
|
389
|
+
let captured: any = null;
|
|
390
|
+
global.fetch = ((_url: string, init?: RequestInit) => {
|
|
391
|
+
captured = JSON.parse(init?.body as string ?? '{}');
|
|
392
|
+
return fakeHeartbeatResp() as any;
|
|
393
|
+
}) as any;
|
|
394
|
+
const t = makeTunnel();
|
|
395
|
+
const fake = new FakeWebSocket();
|
|
396
|
+
fake.readyState = FakeWebSocket.OPEN;
|
|
397
|
+
t._testing().installFakeWs(fake as unknown as WebSocket);
|
|
398
|
+
await t._testing().sendHeartbeat();
|
|
399
|
+
t.stop();
|
|
400
|
+
expect(captured.metadata.tunnelStatus).toBe('connected');
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
it('uses opts.name in heartbeat body', async () => {
|
|
404
|
+
let captured: any = null;
|
|
405
|
+
global.fetch = ((_url: string, init?: RequestInit) => {
|
|
406
|
+
captured = JSON.parse(init?.body as string ?? '{}');
|
|
407
|
+
return fakeHeartbeatResp() as any;
|
|
408
|
+
}) as any;
|
|
409
|
+
const t = makeTunnel({ name: 'my-worker-42' });
|
|
410
|
+
await t._testing().sendHeartbeat();
|
|
411
|
+
t.stop();
|
|
412
|
+
expect(captured.name).toBe('my-worker-42');
|
|
413
|
+
});
|
|
414
|
+
});
|
|
415
|
+
|
|
416
|
+
// ─── sendHeartbeat() (L289-323) ──────────────────────────────────────────────
|
|
417
|
+
|
|
418
|
+
describe('sendHeartbeat() (L289-323)', () => {
|
|
419
|
+
it('POSTs to /api/instances/heartbeat', async () => {
|
|
420
|
+
let calledUrl = '';
|
|
421
|
+
global.fetch = ((url: string) => {
|
|
422
|
+
calledUrl = url;
|
|
423
|
+
return fakeHeartbeatResp() as any;
|
|
424
|
+
}) as any;
|
|
425
|
+
const t = makeTunnel({ cloudUrl: 'https://cloud.test' });
|
|
426
|
+
await t._testing().sendHeartbeat();
|
|
427
|
+
t.stop();
|
|
428
|
+
expect(calledUrl).toBe('https://cloud.test/api/instances/heartbeat');
|
|
429
|
+
});
|
|
430
|
+
|
|
431
|
+
it('returns the parsed HeartbeatResponse', async () => {
|
|
432
|
+
const body = { nextPollIn: 45, wsRequested: true, wsUrl: 'wss://ws.test' };
|
|
433
|
+
global.fetch = () => fakeHeartbeatResp(body) as any;
|
|
434
|
+
const t = makeTunnel();
|
|
435
|
+
const result = await t._testing().sendHeartbeat();
|
|
436
|
+
t.stop();
|
|
437
|
+
expect(result.nextPollIn).toBe(45);
|
|
438
|
+
expect(result.wsRequested).toBe(true);
|
|
439
|
+
});
|
|
440
|
+
|
|
441
|
+
it('throws when response is not ok', async () => {
|
|
442
|
+
global.fetch = () => Promise.resolve(new Response('{}', { status: 401 })) as any;
|
|
443
|
+
const t = makeTunnel();
|
|
444
|
+
await expect(t._testing().sendHeartbeat()).rejects.toThrow('HTTP 401');
|
|
445
|
+
t.stop();
|
|
446
|
+
});
|
|
447
|
+
|
|
448
|
+
it('updates serverPublishedWsUrl when wsUrl is in response', async () => {
|
|
449
|
+
const body = { nextPollIn: 30, wsRequested: false, wsUrl: 'wss://published.test' };
|
|
450
|
+
global.fetch = () => fakeHeartbeatResp(body) as any;
|
|
451
|
+
const t = makeTunnel();
|
|
452
|
+
await t._testing().sendHeartbeat();
|
|
453
|
+
t.stop();
|
|
454
|
+
expect(t._testing().serverPublishedWsUrl).toBe('wss://published.test');
|
|
455
|
+
});
|
|
456
|
+
|
|
457
|
+
it('logs a message when wsUrl changes (new value)', async () => {
|
|
458
|
+
const { logger, logs } = logCapture();
|
|
459
|
+
const body = { nextPollIn: 30, wsRequested: false, wsUrl: 'wss://new-ws.test' };
|
|
460
|
+
global.fetch = () => fakeHeartbeatResp(body) as any;
|
|
461
|
+
const t = makeTunnel({ logger });
|
|
462
|
+
t._testing().serverPublishedWsUrl = 'wss://old-ws.test';
|
|
463
|
+
await t._testing().sendHeartbeat();
|
|
464
|
+
t.stop();
|
|
465
|
+
expect(logs.some(l => l.includes('Cloud advertised tunnel WS URL'))).toBe(true);
|
|
466
|
+
});
|
|
467
|
+
|
|
468
|
+
it('does NOT log when wsUrl is the same as already stored', async () => {
|
|
469
|
+
const { logger, logs } = logCapture();
|
|
470
|
+
const body = { nextPollIn: 30, wsRequested: false, wsUrl: 'wss://same-ws.test' };
|
|
471
|
+
global.fetch = () => fakeHeartbeatResp(body) as any;
|
|
472
|
+
const t = makeTunnel({ logger });
|
|
473
|
+
t._testing().serverPublishedWsUrl = 'wss://same-ws.test';
|
|
474
|
+
await t._testing().sendHeartbeat();
|
|
475
|
+
t.stop();
|
|
476
|
+
expect(logs.some(l => l.includes('Cloud advertised'))).toBe(false);
|
|
477
|
+
});
|
|
478
|
+
});
|
|
479
|
+
|
|
480
|
+
// ─── scheduleNextPoll() (L327-330) ────────────────────────────────────────────
|
|
481
|
+
|
|
482
|
+
describe('scheduleNextPoll() (L327-330)', () => {
|
|
483
|
+
it('no-ops when tunnel is stopped', () => {
|
|
484
|
+
const t = makeTunnel();
|
|
485
|
+
t.stop();
|
|
486
|
+
(t as any).scheduleNextPoll(5);
|
|
487
|
+
// No timer set; just verify no error
|
|
488
|
+
expect(t._testing().stopped).toBe(true);
|
|
489
|
+
});
|
|
490
|
+
|
|
491
|
+
it('schedules heartbeatLoop after given interval', async () => {
|
|
492
|
+
global.fetch = () => fakeHeartbeatResp() as any;
|
|
493
|
+
const t = makeTunnel();
|
|
494
|
+
await t._testing().heartbeatLoop();
|
|
495
|
+
// A poll timer should have been set (stopped=false, has apiKey)
|
|
496
|
+
expect(t.isConnected()).toBe(true);
|
|
497
|
+
t.stop();
|
|
498
|
+
});
|
|
499
|
+
});
|
|
500
|
+
|
|
501
|
+
// ─── heartbeatLoop() (L331-400) ──────────────────────────────────────────────
|
|
502
|
+
|
|
503
|
+
describe('heartbeatLoop() (L331-400)', () => {
|
|
504
|
+
it('returns immediately when stopped', async () => {
|
|
505
|
+
const t = makeTunnel();
|
|
506
|
+
t.stop();
|
|
507
|
+
await t._testing().heartbeatLoop();
|
|
508
|
+
// Just verifying no error and no fetch call
|
|
509
|
+
});
|
|
510
|
+
|
|
511
|
+
it('when WS is OPEN: schedules next poll without fetching', async () => {
|
|
512
|
+
let fetchCalled = false;
|
|
513
|
+
global.fetch = () => { fetchCalled = true; return fakeHeartbeatResp() as any; };
|
|
514
|
+
const t = makeTunnel();
|
|
515
|
+
const fake = new FakeWebSocket();
|
|
516
|
+
fake.readyState = FakeWebSocket.OPEN;
|
|
517
|
+
t._testing().installFakeWs(fake as unknown as WebSocket);
|
|
518
|
+
await t._testing().heartbeatLoop();
|
|
519
|
+
expect(fetchCalled).toBe(false);
|
|
520
|
+
t.stop();
|
|
521
|
+
});
|
|
522
|
+
|
|
523
|
+
it('success: updates currentPollInterval and schedules next poll', async () => {
|
|
524
|
+
global.fetch = () => fakeHeartbeatResp({ nextPollIn: 120, wsRequested: false }) as any;
|
|
525
|
+
const t = makeTunnel();
|
|
526
|
+
await t._testing().heartbeatLoop();
|
|
527
|
+
expect(t._testing().currentPollInterval).toBe(120);
|
|
528
|
+
t.stop();
|
|
529
|
+
});
|
|
530
|
+
|
|
531
|
+
it('success: clears lastHeartbeatError and logs recovered', async () => {
|
|
532
|
+
const { logger, logs } = logCapture();
|
|
533
|
+
global.fetch = () => fakeHeartbeatResp() as any;
|
|
534
|
+
const t = makeTunnel({ logger });
|
|
535
|
+
// Manually set a previous error
|
|
536
|
+
(t as any).lastHeartbeatError = 'previous error';
|
|
537
|
+
await t._testing().heartbeatLoop();
|
|
538
|
+
expect(logs.some(l => l.includes('recovered'))).toBe(true);
|
|
539
|
+
expect((t as any).lastHeartbeatError).toBeNull();
|
|
540
|
+
t.stop();
|
|
541
|
+
});
|
|
542
|
+
|
|
543
|
+
it('success: wsRequested=true triggers connectWs', async () => {
|
|
544
|
+
const factory = installFakeWsFactory();
|
|
545
|
+
global.fetch = () => fakeHeartbeatResp({ nextPollIn: 30, wsRequested: true }) as any;
|
|
546
|
+
const t = makeTunnel();
|
|
547
|
+
await t._testing().heartbeatLoop();
|
|
548
|
+
// connectWs should have been called → a FakeWebSocket was created
|
|
549
|
+
expect(factory.last()).not.toBeNull();
|
|
550
|
+
t.stop();
|
|
551
|
+
});
|
|
552
|
+
|
|
553
|
+
it('success: in auth backoff but below recovery threshold → keeps backoff', async () => {
|
|
554
|
+
global.fetch = () => fakeHeartbeatResp({ nextPollIn: 30, wsRequested: false }) as any;
|
|
555
|
+
const t = makeTunnel();
|
|
556
|
+
t._testing().currentPollInterval = 300; // AUTH_FAILURE_BACKOFF_S
|
|
557
|
+
(t as any).consecutiveAuthFailures = 3; // >= threshold (AUTH_FAILURE_THRESHOLD)
|
|
558
|
+
(t as any).consecutiveAuthSuccesses = 0;
|
|
559
|
+
await t._testing().heartbeatLoop();
|
|
560
|
+
// consecutiveAuthSuccesses incremented to 1, still < AUTH_RECOVERY_SUCCESS_THRESHOLD(3)
|
|
561
|
+
expect(t._testing().currentPollInterval).toBe(300);
|
|
562
|
+
t.stop();
|
|
563
|
+
});
|
|
564
|
+
|
|
565
|
+
it('success: in auth backoff — threshold met → resets failures', async () => {
|
|
566
|
+
global.fetch = () => fakeHeartbeatResp({ nextPollIn: 45, wsRequested: false }) as any;
|
|
567
|
+
const t = makeTunnel();
|
|
568
|
+
(t as any).consecutiveAuthFailures = 3;
|
|
569
|
+
(t as any).consecutiveAuthSuccesses = 2; // one more → meets threshold (3)
|
|
570
|
+
await t._testing().heartbeatLoop();
|
|
571
|
+
expect((t as any).consecutiveAuthFailures).toBe(0);
|
|
572
|
+
expect(t._testing().currentPollInterval).toBe(45);
|
|
573
|
+
t.stop();
|
|
574
|
+
});
|
|
575
|
+
|
|
576
|
+
it('error: HTTP 401 increments consecutiveAuthFailures', async () => {
|
|
577
|
+
global.fetch = () => Promise.resolve(new Response('{}', { status: 401 })) as any;
|
|
578
|
+
const t = makeTunnel();
|
|
579
|
+
await t._testing().heartbeatLoop();
|
|
580
|
+
expect((t as any).consecutiveAuthFailures).toBe(1);
|
|
581
|
+
t.stop();
|
|
582
|
+
});
|
|
583
|
+
|
|
584
|
+
it('error: HTTP 403 also increments consecutiveAuthFailures', async () => {
|
|
585
|
+
global.fetch = () => Promise.resolve(new Response('{}', { status: 403 })) as any;
|
|
586
|
+
const t = makeTunnel();
|
|
587
|
+
await t._testing().heartbeatLoop();
|
|
588
|
+
expect((t as any).consecutiveAuthFailures).toBe(1);
|
|
589
|
+
t.stop();
|
|
590
|
+
});
|
|
591
|
+
|
|
592
|
+
it('error: 3 consecutive auth failures calls onAuthRevoked', async () => {
|
|
593
|
+
global.fetch = () => Promise.resolve(new Response('{}', { status: 401 })) as any;
|
|
594
|
+
let revokedReason = '';
|
|
595
|
+
const t = makeTunnel({ onAuthRevoked: (r) => { revokedReason = r; } });
|
|
596
|
+
(t as any).consecutiveAuthFailures = 2; // one more will hit threshold
|
|
597
|
+
await t._testing().heartbeatLoop();
|
|
598
|
+
expect(revokedReason).toContain('consecutive auth failures');
|
|
599
|
+
t.stop();
|
|
600
|
+
});
|
|
601
|
+
|
|
602
|
+
it('error: onAuthRevoked callback throws → logs warning', async () => {
|
|
603
|
+
const { logger, warns } = logCapture();
|
|
604
|
+
global.fetch = () => Promise.resolve(new Response('{}', { status: 401 })) as any;
|
|
605
|
+
const t = makeTunnel({
|
|
606
|
+
logger,
|
|
607
|
+
onAuthRevoked: () => { throw new Error('cb threw'); },
|
|
608
|
+
});
|
|
609
|
+
(t as any).consecutiveAuthFailures = 2;
|
|
610
|
+
await t._testing().heartbeatLoop();
|
|
611
|
+
expect(warns.some(w => w.includes('onAuthRevoked threw'))).toBe(true);
|
|
612
|
+
t.stop();
|
|
613
|
+
});
|
|
614
|
+
|
|
615
|
+
it('error: already at backoff interval → no duplicate warn', async () => {
|
|
616
|
+
const { logger, warns } = logCapture();
|
|
617
|
+
global.fetch = () => Promise.resolve(new Response('{}', { status: 401 })) as any;
|
|
618
|
+
const t = makeTunnel({ logger });
|
|
619
|
+
(t as any).consecutiveAuthFailures = 2;
|
|
620
|
+
(t as any).currentPollInterval = 300; // already at AUTH_FAILURE_BACKOFF_S
|
|
621
|
+
await t._testing().heartbeatLoop();
|
|
622
|
+
expect(warns.some(w => w.includes('backing off'))).toBe(false);
|
|
623
|
+
t.stop();
|
|
624
|
+
});
|
|
625
|
+
|
|
626
|
+
it('error: non-auth failure resets consecutiveAuthFailures', async () => {
|
|
627
|
+
global.fetch = () => Promise.reject(new Error('network error')) as any;
|
|
628
|
+
const t = makeTunnel();
|
|
629
|
+
(t as any).consecutiveAuthFailures = 2;
|
|
630
|
+
await t._testing().heartbeatLoop();
|
|
631
|
+
expect((t as any).consecutiveAuthFailures).toBe(0);
|
|
632
|
+
t.stop();
|
|
633
|
+
});
|
|
634
|
+
|
|
635
|
+
it('error: repeated same error message is not re-logged', async () => {
|
|
636
|
+
const { logger, errors } = logCapture();
|
|
637
|
+
global.fetch = () => Promise.reject(new Error('same error')) as any;
|
|
638
|
+
const t = makeTunnel({ logger });
|
|
639
|
+
await t._testing().heartbeatLoop();
|
|
640
|
+
const countAfterFirst = errors.length;
|
|
641
|
+
(t as any).currentPollInterval = 1; // don't want to wait
|
|
642
|
+
await t._testing().heartbeatLoop();
|
|
643
|
+
// Second loop should not add a new error log for the same message
|
|
644
|
+
expect(errors.length).toBe(countAfterFirst);
|
|
645
|
+
t.stop();
|
|
646
|
+
});
|
|
647
|
+
|
|
648
|
+
it('error: non-auth failure → currentPollInterval resets to DEFAULT', async () => {
|
|
649
|
+
global.fetch = () => Promise.reject(new Error('connection refused')) as any;
|
|
650
|
+
const t = makeTunnel();
|
|
651
|
+
t._testing().currentPollInterval = 300;
|
|
652
|
+
await t._testing().heartbeatLoop();
|
|
653
|
+
expect(t._testing().currentPollInterval).toBe(60); // DEFAULT_POLL_INTERVAL_S
|
|
654
|
+
t.stop();
|
|
655
|
+
});
|
|
656
|
+
});
|
|
657
|
+
|
|
658
|
+
// ─── resetWsIdleTimer() callback body (L539-542) ─────────────────────────────
|
|
659
|
+
|
|
660
|
+
describe('resetWsIdleTimer() idle callback (L539-542)', () => {
|
|
661
|
+
it('closes ws when callback fires with OPEN ws', async () => {
|
|
662
|
+
// We use fake timers by directly extracting the callback via spying on globalThis.setTimeout
|
|
663
|
+
const callbacks: Array<() => void> = [];
|
|
664
|
+
const origST = globalThis.setTimeout;
|
|
665
|
+
(globalThis as any).setTimeout = (cb: () => void, _ms: number) => {
|
|
666
|
+
callbacks.push(cb);
|
|
667
|
+
return 0 as any;
|
|
668
|
+
};
|
|
669
|
+
|
|
670
|
+
try {
|
|
671
|
+
const t = makeTunnel();
|
|
672
|
+
const fake = new FakeWebSocket();
|
|
673
|
+
fake.readyState = FakeWebSocket.OPEN;
|
|
674
|
+
t._testing().installFakeWs(fake as unknown as WebSocket);
|
|
675
|
+
// Call handleRequest which calls resetWsIdleTimer
|
|
676
|
+
await t._testing().handleRequest({
|
|
677
|
+
type: 'request', requestId: 'x', method: 'GET', path: '/agent/test',
|
|
678
|
+
} as any);
|
|
679
|
+
// Now fire the idle callback we captured
|
|
680
|
+
const idleCb = callbacks[callbacks.length - 1];
|
|
681
|
+
idleCb?.();
|
|
682
|
+
expect(fake.closedWith?.code).toBe(1000);
|
|
683
|
+
expect(fake.closedWith?.reason).toBe('Idle timeout');
|
|
684
|
+
} finally {
|
|
685
|
+
(globalThis as any).setTimeout = origST;
|
|
686
|
+
}
|
|
687
|
+
});
|
|
688
|
+
|
|
689
|
+
it('idle callback no-ops when ws readyState is not OPEN', async () => {
|
|
690
|
+
const callbacks: Array<() => void> = [];
|
|
691
|
+
const origST = globalThis.setTimeout;
|
|
692
|
+
(globalThis as any).setTimeout = (cb: () => void, _ms: number) => {
|
|
693
|
+
callbacks.push(cb);
|
|
694
|
+
return 0 as any;
|
|
695
|
+
};
|
|
696
|
+
|
|
697
|
+
try {
|
|
698
|
+
const t = makeTunnel();
|
|
699
|
+
const fake = new FakeWebSocket();
|
|
700
|
+
fake.readyState = FakeWebSocket.CLOSED;
|
|
701
|
+
t._testing().installFakeWs(fake as unknown as WebSocket);
|
|
702
|
+
await t._testing().handleRequest({
|
|
703
|
+
type: 'request', requestId: 'y', method: 'GET', path: '/agent/test',
|
|
704
|
+
} as any);
|
|
705
|
+
const idleCb = callbacks[callbacks.length - 1];
|
|
706
|
+
idleCb?.();
|
|
707
|
+
expect(fake.closedWith).toBeNull(); // close was NOT called
|
|
708
|
+
} finally {
|
|
709
|
+
(globalThis as any).setTimeout = origST;
|
|
710
|
+
}
|
|
711
|
+
});
|
|
712
|
+
});
|
|
713
|
+
|
|
714
|
+
// ─── startWsHeartbeat() setInterval callback (L547-554) ───────────────────────
|
|
715
|
+
|
|
716
|
+
describe('startWsHeartbeat() callback (L547-554)', () => {
|
|
717
|
+
it('sends heartbeat frame when ws is OPEN', async () => {
|
|
718
|
+
const callbacks: Array<() => void> = [];
|
|
719
|
+
const origSI = globalThis.setInterval;
|
|
720
|
+
(globalThis as any).setInterval = (cb: () => void, _ms: number) => {
|
|
721
|
+
callbacks.push(cb);
|
|
722
|
+
return 0 as any;
|
|
723
|
+
};
|
|
724
|
+
const origST = globalThis.setTimeout;
|
|
725
|
+
(globalThis as any).setTimeout = (_cb: () => void, _ms: number) => 0 as any;
|
|
726
|
+
|
|
727
|
+
try {
|
|
728
|
+
global.fetch = () => fakeHeartbeatResp({ nextPollIn: 30, wsRequested: true }) as any;
|
|
729
|
+
const factory = installFakeWsFactory();
|
|
730
|
+
const t = makeTunnel();
|
|
731
|
+
await t._testing().heartbeatLoop();
|
|
732
|
+
const ws = factory.last()!;
|
|
733
|
+
expect(ws).not.toBeNull();
|
|
734
|
+
// trigger onopen (which calls startWsHeartbeat + resetWsIdleTimer)
|
|
735
|
+
ws.triggerOpen();
|
|
736
|
+
// Fire the heartbeat setInterval callback
|
|
737
|
+
const hbCb = callbacks[callbacks.length - 1];
|
|
738
|
+
ws.readyState = FakeWebSocket.OPEN;
|
|
739
|
+
t._testing().installFakeWs(ws as unknown as WebSocket);
|
|
740
|
+
await hbCb?.();
|
|
741
|
+
// Should have sent a 'heartbeat' frame
|
|
742
|
+
expect(ws.sent.some((s) => JSON.parse(s).type === 'heartbeat')).toBe(true);
|
|
743
|
+
t.stop();
|
|
744
|
+
} finally {
|
|
745
|
+
(globalThis as any).setInterval = origSI;
|
|
746
|
+
(globalThis as any).setTimeout = origST;
|
|
747
|
+
}
|
|
748
|
+
});
|
|
749
|
+
|
|
750
|
+
it('heartbeat callback no-ops when ws is not OPEN', async () => {
|
|
751
|
+
const callbacks: Array<() => void> = [];
|
|
752
|
+
const origSI = globalThis.setInterval;
|
|
753
|
+
const origST = globalThis.setTimeout;
|
|
754
|
+
(globalThis as any).setInterval = (cb: () => void, _ms: number) => {
|
|
755
|
+
callbacks.push(cb);
|
|
756
|
+
return 0 as any;
|
|
757
|
+
};
|
|
758
|
+
(globalThis as any).setTimeout = (_cb: () => void, _ms: number) => 0 as any;
|
|
759
|
+
|
|
760
|
+
try {
|
|
761
|
+
global.fetch = () => fakeHeartbeatResp({ nextPollIn: 30, wsRequested: true }) as any;
|
|
762
|
+
const factory = installFakeWsFactory();
|
|
763
|
+
const t = makeTunnel();
|
|
764
|
+
await t._testing().heartbeatLoop();
|
|
765
|
+
const ws = factory.last()!;
|
|
766
|
+
ws.triggerOpen();
|
|
767
|
+
ws.readyState = FakeWebSocket.CLOSED;
|
|
768
|
+
const hbCb = callbacks[callbacks.length - 1];
|
|
769
|
+
await hbCb?.();
|
|
770
|
+
expect(ws.sent.length).toBe(0);
|
|
771
|
+
t.stop();
|
|
772
|
+
} finally {
|
|
773
|
+
(globalThis as any).setInterval = origSI;
|
|
774
|
+
(globalThis as any).setTimeout = origST;
|
|
775
|
+
}
|
|
776
|
+
});
|
|
777
|
+
});
|
|
778
|
+
|
|
779
|
+
// ─── connectWs() (L558-642) ──────────────────────────────────────────────────
|
|
780
|
+
|
|
781
|
+
describe('connectWs() (L558-642)', () => {
|
|
782
|
+
it('no-ops when stopped=true', () => {
|
|
783
|
+
const factory = installFakeWsFactory();
|
|
784
|
+
const t = makeTunnel();
|
|
785
|
+
t.stop();
|
|
786
|
+
t._testing().connectWs();
|
|
787
|
+
expect(factory.last()).toBeNull();
|
|
788
|
+
});
|
|
789
|
+
|
|
790
|
+
it('no-ops when ws is already set', () => {
|
|
791
|
+
const factory = installFakeWsFactory();
|
|
792
|
+
const t = makeTunnel();
|
|
793
|
+
const fake = new FakeWebSocket();
|
|
794
|
+
t._testing().installFakeWs(fake as unknown as WebSocket);
|
|
795
|
+
t._testing().connectWs();
|
|
796
|
+
expect(factory.last()).toBeNull(); // no new WS created
|
|
797
|
+
t.stop();
|
|
798
|
+
});
|
|
799
|
+
|
|
800
|
+
it('logs error and schedules poll when createTunnelWebSocket throws', () => {
|
|
801
|
+
const { logger, errors } = logCapture();
|
|
802
|
+
const t = makeTunnel({ logger });
|
|
803
|
+
// Make WebSocket constructor throw — supportsWebSocketConstructorHeaders returns
|
|
804
|
+
// true in Bun, but the actual constructor call blows up.
|
|
805
|
+
function ThrowingWS() { throw new Error('ws-unavailable'); }
|
|
806
|
+
ThrowingWS.OPEN = 1; ThrowingWS.CONNECTING = 0; ThrowingWS.CLOSING = 2; ThrowingWS.CLOSED = 3;
|
|
807
|
+
(globalThis as any).WebSocket = ThrowingWS;
|
|
808
|
+
t._testing().connectWs();
|
|
809
|
+
expect(errors.some(e => e.includes('WebSocket creation failed'))).toBe(true);
|
|
810
|
+
t.stop();
|
|
811
|
+
});
|
|
812
|
+
|
|
813
|
+
it('normal flow: creates WebSocket, sets this.ws', () => {
|
|
814
|
+
const factory = installFakeWsFactory();
|
|
815
|
+
const t = makeTunnel();
|
|
816
|
+
t._testing().connectWs();
|
|
817
|
+
const ws = factory.last();
|
|
818
|
+
expect(ws).not.toBeNull();
|
|
819
|
+
expect(t._testing().ws).toBe(ws);
|
|
820
|
+
t.stop();
|
|
821
|
+
});
|
|
822
|
+
|
|
823
|
+
it('WS url includes /api/instances/ws', () => {
|
|
824
|
+
const factory = installFakeWsFactory();
|
|
825
|
+
const t = makeTunnel({ cloudUrl: 'https://api.test' });
|
|
826
|
+
t._testing().connectWs();
|
|
827
|
+
const ws = factory.last()!;
|
|
828
|
+
expect(ws.url).toContain('/api/instances/ws');
|
|
829
|
+
t.stop();
|
|
830
|
+
});
|
|
831
|
+
});
|
|
832
|
+
|
|
833
|
+
// ─── WS event handlers (L643-659) ────────────────────────────────────────────
|
|
834
|
+
|
|
835
|
+
describe('connectWs() socket.onopen (L643-645)', () => {
|
|
836
|
+
it('resets wsReconnectAttempt to 0 and sets timers', () => {
|
|
837
|
+
const factory = installFakeWsFactory();
|
|
838
|
+
const t = makeTunnel();
|
|
839
|
+
t._testing().wsReconnectAttempt = 5;
|
|
840
|
+
t._testing().connectWs();
|
|
841
|
+
const ws = factory.last()!;
|
|
842
|
+
ws.triggerOpen();
|
|
843
|
+
expect(t._testing().wsReconnectAttempt).toBe(0);
|
|
844
|
+
t.stop();
|
|
845
|
+
});
|
|
846
|
+
});
|
|
847
|
+
|
|
848
|
+
describe('connectWs() socket.onmessage (L647-610)', () => {
|
|
849
|
+
it('ignores invalid JSON', () => {
|
|
850
|
+
const factory = installFakeWsFactory();
|
|
851
|
+
const t = makeTunnel();
|
|
852
|
+
t._testing().connectWs();
|
|
853
|
+
const ws = factory.last()!;
|
|
854
|
+
expect(() => ws.triggerMessage('{bad json')).not.toThrow();
|
|
855
|
+
t.stop();
|
|
856
|
+
});
|
|
857
|
+
|
|
858
|
+
it('ping message → sends pong', () => {
|
|
859
|
+
const factory = installFakeWsFactory();
|
|
860
|
+
const t = makeTunnel();
|
|
861
|
+
t._testing().connectWs();
|
|
862
|
+
const ws = factory.last()!;
|
|
863
|
+
ws.readyState = FakeWebSocket.OPEN;
|
|
864
|
+
t._testing().installFakeWs(ws as unknown as WebSocket);
|
|
865
|
+
ws.triggerMessage({ type: 'ping' });
|
|
866
|
+
expect(ws.sent.some((s) => JSON.parse(s).type === 'pong')).toBe(true);
|
|
867
|
+
t.stop();
|
|
868
|
+
});
|
|
869
|
+
|
|
870
|
+
it('cancel message → aborts corresponding controller', async () => {
|
|
871
|
+
const factory = installFakeWsFactory();
|
|
872
|
+
const t = makeTunnel();
|
|
873
|
+
t._testing().connectWs();
|
|
874
|
+
const ws = factory.last()!;
|
|
875
|
+
ws.readyState = FakeWebSocket.OPEN;
|
|
876
|
+
t._testing().installFakeWs(ws as unknown as WebSocket);
|
|
877
|
+
|
|
878
|
+
// Inject a controller manually
|
|
879
|
+
const ctrl = new AbortController();
|
|
880
|
+
(t as any).activeAbortControllers.set('req-abc', ctrl);
|
|
881
|
+
|
|
882
|
+
ws.triggerMessage({ type: 'cancel', requestId: 'req-abc' });
|
|
883
|
+
expect(ctrl.signal.aborted).toBe(true);
|
|
884
|
+
t.stop();
|
|
885
|
+
});
|
|
886
|
+
|
|
887
|
+
it('request message → dispatches to handleRequest (sends 502)', async () => {
|
|
888
|
+
const factory = installFakeWsFactory();
|
|
889
|
+
const t = makeTunnel({
|
|
890
|
+
resolver: makeResolver({ resolveLocalUrl: async () => null }),
|
|
891
|
+
});
|
|
892
|
+
t._testing().connectWs();
|
|
893
|
+
const ws = factory.last()!;
|
|
894
|
+
ws.readyState = FakeWebSocket.OPEN;
|
|
895
|
+
t._testing().installFakeWs(ws as unknown as WebSocket);
|
|
896
|
+
// Await the handleRequest call directly so we don't need setTimeout
|
|
897
|
+
await t._testing().handleRequest({ type: 'request', requestId: 'r-1', method: 'GET', path: '/x' } as any);
|
|
898
|
+
expect(ws.sent.length).toBeGreaterThan(0);
|
|
899
|
+
const frame = JSON.parse(ws.sent[0]!);
|
|
900
|
+
expect(frame.type).toBe('response');
|
|
901
|
+
expect(frame.status).toBe(502);
|
|
902
|
+
t.stop();
|
|
903
|
+
});
|
|
904
|
+
|
|
905
|
+
it('unknown message type → ignored silently', () => {
|
|
906
|
+
const factory = installFakeWsFactory();
|
|
907
|
+
const t = makeTunnel();
|
|
908
|
+
t._testing().connectWs();
|
|
909
|
+
const ws = factory.last()!;
|
|
910
|
+
ws.readyState = FakeWebSocket.OPEN;
|
|
911
|
+
t._testing().installFakeWs(ws as unknown as WebSocket);
|
|
912
|
+
expect(() => ws.triggerMessage({ type: 'future-unknown-type' })).not.toThrow();
|
|
913
|
+
t.stop();
|
|
914
|
+
});
|
|
915
|
+
|
|
916
|
+
it('request message .catch() fires when handleRequest rejects (L614-615)', async () => {
|
|
917
|
+
// Make handleRequest reject by making setTimeout throw inside resetWsIdleTimer.
|
|
918
|
+
// handleRequest is async, so a synchronous throw before the first await
|
|
919
|
+
// makes it return a rejected promise. The void+.catch() at L614 logs the error.
|
|
920
|
+
const { logger, errors } = logCapture();
|
|
921
|
+
const factory = installFakeWsFactory();
|
|
922
|
+
const t = makeTunnel({ logger });
|
|
923
|
+
t._testing().connectWs();
|
|
924
|
+
const ws = factory.last()!;
|
|
925
|
+
ws.readyState = FakeWebSocket.OPEN;
|
|
926
|
+
t._testing().installFakeWs(ws as unknown as WebSocket);
|
|
927
|
+
|
|
928
|
+
const origST = globalThis.setTimeout;
|
|
929
|
+
(globalThis as any).setTimeout = () => { throw new Error('timer exploded'); };
|
|
930
|
+
try {
|
|
931
|
+
ws.triggerMessage({ type: 'request', requestId: 'r-boom', method: 'GET', path: '/x' });
|
|
932
|
+
// Allow the rejection microtask to run (no setTimeout needed — pure microtask tick)
|
|
933
|
+
await Promise.resolve();
|
|
934
|
+
await Promise.resolve();
|
|
935
|
+
} finally {
|
|
936
|
+
(globalThis as any).setTimeout = origST;
|
|
937
|
+
}
|
|
938
|
+
expect(errors.some(e => e.includes('Error handling request'))).toBe(true);
|
|
939
|
+
t.stop();
|
|
940
|
+
});
|
|
941
|
+
});
|
|
942
|
+
|
|
943
|
+
describe('connectWs() socket.onclose (L612-627)', () => {
|
|
944
|
+
beforeEach(() => {
|
|
945
|
+
global.fetch = () => fakeHeartbeatResp() as any;
|
|
946
|
+
});
|
|
947
|
+
|
|
948
|
+
it('code 1000: schedules normal poll (no backoff)', () => {
|
|
949
|
+
const factory = installFakeWsFactory();
|
|
950
|
+
const { logger, logs } = logCapture();
|
|
951
|
+
const t = makeTunnel({ logger });
|
|
952
|
+
t._testing().connectWs();
|
|
953
|
+
const ws = factory.last()!;
|
|
954
|
+
ws.triggerClose(1000, 'Idle timeout');
|
|
955
|
+
expect(logs.some(l => l.includes('closed'))).toBe(true);
|
|
956
|
+
expect(t._testing().wsReconnectAttempt).toBe(0); // no increment
|
|
957
|
+
t.stop();
|
|
958
|
+
});
|
|
959
|
+
|
|
960
|
+
it('code 4000: schedules normal poll (treated like 1000)', () => {
|
|
961
|
+
const factory = installFakeWsFactory();
|
|
962
|
+
const t = makeTunnel();
|
|
963
|
+
t._testing().connectWs();
|
|
964
|
+
const ws = factory.last()!;
|
|
965
|
+
ws.triggerClose(4000, '');
|
|
966
|
+
expect(t._testing().wsReconnectAttempt).toBe(0);
|
|
967
|
+
t.stop();
|
|
968
|
+
});
|
|
969
|
+
|
|
970
|
+
it('other code: increments wsReconnectAttempt and logs reconnect', () => {
|
|
971
|
+
const factory = installFakeWsFactory();
|
|
972
|
+
const { logger, logs } = logCapture();
|
|
973
|
+
const t = makeTunnel({ logger });
|
|
974
|
+
t._testing().connectWs();
|
|
975
|
+
const ws = factory.last()!;
|
|
976
|
+
ws.triggerClose(1006, 'abnormal closure');
|
|
977
|
+
expect(t._testing().wsReconnectAttempt).toBe(1);
|
|
978
|
+
expect(logs.some(l => l.includes('Reconnecting'))).toBe(true);
|
|
979
|
+
t.stop();
|
|
980
|
+
});
|
|
981
|
+
|
|
982
|
+
it('when stopped: onclose does not schedule poll', () => {
|
|
983
|
+
const factory = installFakeWsFactory();
|
|
984
|
+
const t = makeTunnel();
|
|
985
|
+
t._testing().connectWs();
|
|
986
|
+
const ws = factory.last()!;
|
|
987
|
+
t.stop();
|
|
988
|
+
ws.triggerClose(1006);
|
|
989
|
+
expect(t._testing().stopped).toBe(true);
|
|
990
|
+
});
|
|
991
|
+
});
|
|
992
|
+
|
|
993
|
+
describe('connectWs() socket.onerror (L629-631)', () => {
|
|
994
|
+
it('logs the error message', () => {
|
|
995
|
+
const factory = installFakeWsFactory();
|
|
996
|
+
const { logger, errors } = logCapture();
|
|
997
|
+
const t = makeTunnel({ logger });
|
|
998
|
+
t._testing().connectWs();
|
|
999
|
+
const ws = factory.last()!;
|
|
1000
|
+
ws.triggerError('SSL handshake failed');
|
|
1001
|
+
expect(errors.some(e => e.includes('WebSocket error'))).toBe(true);
|
|
1002
|
+
t.stop();
|
|
1003
|
+
});
|
|
1004
|
+
|
|
1005
|
+
it('handles onerror with no message', () => {
|
|
1006
|
+
const factory = installFakeWsFactory();
|
|
1007
|
+
const t = makeTunnel();
|
|
1008
|
+
t._testing().connectWs();
|
|
1009
|
+
const ws = factory.last()!;
|
|
1010
|
+
expect(() => ws.onerror?.({})).not.toThrow();
|
|
1011
|
+
t.stop();
|
|
1012
|
+
});
|
|
1013
|
+
});
|
|
1014
|
+
|
|
1015
|
+
// ─── cleanupWs() (L662-660) ──────────────────────────────────────────────────
|
|
1016
|
+
|
|
1017
|
+
describe('cleanupWs() (L634-660)', () => {
|
|
1018
|
+
it('clears heartbeatTimer and wsIdleTimer, aborts controllers, nulls ws', () => {
|
|
1019
|
+
const t = makeTunnel();
|
|
1020
|
+
// Directly set truthy timer handles to avoid relying on fake-setTimeout quirks
|
|
1021
|
+
(t as any).heartbeatTimer = setInterval(() => {}, 9_999_999);
|
|
1022
|
+
(t as any).wsIdleTimer = setTimeout(() => {}, 9_999_999);
|
|
1023
|
+
// Install a fake ws so _testing().ws is not null before cleanup
|
|
1024
|
+
const fake = new FakeWebSocket();
|
|
1025
|
+
t._testing().installFakeWs(fake as unknown as WebSocket);
|
|
1026
|
+
// Inject an abort controller
|
|
1027
|
+
const ctrl = new AbortController();
|
|
1028
|
+
(t as any).activeAbortControllers.set('test-req', ctrl);
|
|
1029
|
+
|
|
1030
|
+
t._testing().cleanupWs();
|
|
1031
|
+
expect(ctrl.signal.aborted).toBe(true);
|
|
1032
|
+
expect(t._testing().ws).toBeNull();
|
|
1033
|
+
expect((t as any).heartbeatTimer).toBeNull();
|
|
1034
|
+
expect((t as any).wsIdleTimer).toBeNull();
|
|
1035
|
+
t.stop();
|
|
1036
|
+
});
|
|
1037
|
+
});
|
|
1038
|
+
|
|
1039
|
+
// ─── _testing() proxies + getters/setters (L682-700) ──────────────────────────
|
|
1040
|
+
|
|
1041
|
+
describe('_testing() proxy methods (L682-700)', () => {
|
|
1042
|
+
it('supportsWebSocketConstructorHeaders proxy works', () => {
|
|
1043
|
+
const t = makeTunnel();
|
|
1044
|
+
const result = t._testing().supportsWebSocketConstructorHeaders({ Bun: {} } as any);
|
|
1045
|
+
expect(result).toBe(true);
|
|
1046
|
+
});
|
|
1047
|
+
|
|
1048
|
+
it('createTunnelWebSocket proxy throws on non-Bun runtime', () => {
|
|
1049
|
+
const t = makeTunnel();
|
|
1050
|
+
expect(() =>
|
|
1051
|
+
t._testing().createTunnelWebSocket('wss://x', { headers: {} }, {} as any)
|
|
1052
|
+
).toThrow(TunnelWebSocketHeaderSupportError);
|
|
1053
|
+
});
|
|
1054
|
+
|
|
1055
|
+
it('TUNNEL_PROTOCOL_VERSION is exported via _testing()', () => {
|
|
1056
|
+
const t = makeTunnel();
|
|
1057
|
+
expect(t._testing().TUNNEL_PROTOCOL_VERSION).toBe(TUNNEL_PROTOCOL_VERSION);
|
|
1058
|
+
});
|
|
1059
|
+
|
|
1060
|
+
it('currentPollInterval get/set roundtrip', () => {
|
|
1061
|
+
const t = makeTunnel();
|
|
1062
|
+
t._testing().currentPollInterval = 42;
|
|
1063
|
+
expect(t._testing().currentPollInterval).toBe(42);
|
|
1064
|
+
});
|
|
1065
|
+
|
|
1066
|
+
it('wsReconnectAttempt get/set roundtrip', () => {
|
|
1067
|
+
const t = makeTunnel();
|
|
1068
|
+
t._testing().wsReconnectAttempt = 7;
|
|
1069
|
+
expect(t._testing().wsReconnectAttempt).toBe(7);
|
|
1070
|
+
});
|
|
1071
|
+
|
|
1072
|
+
it('serverPublishedWsUrl get/set roundtrip', () => {
|
|
1073
|
+
const t = makeTunnel();
|
|
1074
|
+
t._testing().serverPublishedWsUrl = 'wss://test.ws';
|
|
1075
|
+
expect(t._testing().serverPublishedWsUrl).toBe('wss://test.ws');
|
|
1076
|
+
t._testing().serverPublishedWsUrl = null;
|
|
1077
|
+
expect(t._testing().serverPublishedWsUrl).toBeNull();
|
|
1078
|
+
});
|
|
1079
|
+
|
|
1080
|
+
it('ws getter reflects installed fake', () => {
|
|
1081
|
+
const t = makeTunnel();
|
|
1082
|
+
const fake = new FakeWebSocket();
|
|
1083
|
+
t._testing().installFakeWs(fake as unknown as WebSocket);
|
|
1084
|
+
expect(t._testing().ws).toBe(fake);
|
|
1085
|
+
t.stop();
|
|
1086
|
+
});
|
|
1087
|
+
|
|
1088
|
+
it('stopped getter reflects current state', () => {
|
|
1089
|
+
const t = makeTunnel();
|
|
1090
|
+
expect(t._testing().stopped).toBe(false);
|
|
1091
|
+
t.stop();
|
|
1092
|
+
expect(t._testing().stopped).toBe(true);
|
|
1093
|
+
});
|
|
1094
|
+
});
|