@ripeseed/rs-tunnel 0.3.0 → 0.4.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/dist/commands/doctor.js +34 -4
- package/dist/commands/doctor.js.map +1 -1
- package/dist/commands/doctor.test.d.ts +1 -0
- package/dist/commands/doctor.test.js +80 -0
- package/dist/commands/doctor.test.js.map +1 -0
- package/dist/commands/list.js +45 -7
- package/dist/commands/list.js.map +1 -1
- package/dist/commands/list.test.d.ts +1 -0
- package/dist/commands/list.test.js +84 -0
- package/dist/commands/list.test.js.map +1 -0
- package/dist/commands/login.js +102 -18
- package/dist/commands/login.js.map +1 -1
- package/dist/commands/login.test.js +109 -3
- package/dist/commands/login.test.js.map +1 -1
- package/dist/commands/logout.js +18 -1
- package/dist/commands/logout.js.map +1 -1
- package/dist/commands/logout.test.d.ts +1 -0
- package/dist/commands/logout.test.js +78 -0
- package/dist/commands/logout.test.js.map +1 -0
- package/dist/commands/stop.js +18 -1
- package/dist/commands/stop.js.map +1 -1
- package/dist/commands/stop.test.d.ts +1 -0
- package/dist/commands/stop.test.js +46 -0
- package/dist/commands/stop.test.js.map +1 -0
- package/dist/commands/up.d.ts +12 -2
- package/dist/commands/up.js +349 -74
- package/dist/commands/up.js.map +1 -1
- package/dist/commands/up.test.js +589 -233
- package/dist/commands/up.test.js.map +1 -1
- package/dist/lib/clipboard.d.ts +1 -0
- package/dist/lib/clipboard.js +50 -0
- package/dist/lib/clipboard.js.map +1 -0
- package/dist/lib/local-target.d.ts +12 -0
- package/dist/lib/local-target.js +48 -0
- package/dist/lib/local-target.js.map +1 -0
- package/dist/lib/local-target.test.d.ts +1 -0
- package/dist/lib/local-target.test.js +23 -0
- package/dist/lib/local-target.test.js.map +1 -0
- package/dist/lib/up-dashboard.test.js +141 -51
- package/dist/lib/up-dashboard.test.js.map +1 -1
- package/dist/lib/up-runtime.d.ts +110 -0
- package/dist/lib/up-runtime.js +455 -0
- package/dist/lib/up-runtime.js.map +1 -0
- package/dist/ui/index.d.ts +2 -0
- package/dist/ui/index.js +3 -0
- package/dist/ui/index.js.map +1 -0
- package/dist/ui/output.d.ts +20 -0
- package/dist/ui/output.js +50 -0
- package/dist/ui/output.js.map +1 -0
- package/dist/ui/output.test.d.ts +1 -0
- package/dist/ui/output.test.js +84 -0
- package/dist/ui/output.test.js.map +1 -0
- package/dist/ui/primitives.d.ts +50 -0
- package/dist/ui/primitives.js +102 -0
- package/dist/ui/primitives.js.map +1 -0
- package/dist/ui/primitives.test.d.ts +1 -0
- package/dist/ui/primitives.test.js +46 -0
- package/dist/ui/primitives.test.js.map +1 -0
- package/dist/ui/test-utils.d.ts +10 -0
- package/dist/ui/test-utils.js +71 -0
- package/dist/ui/test-utils.js.map +1 -0
- package/dist/ui/up/UpApp.d.ts +5 -0
- package/dist/ui/up/UpApp.js +47 -0
- package/dist/ui/up/UpApp.js.map +1 -0
- package/dist/ui/up/index.d.ts +4 -0
- package/dist/ui/up/index.js +3 -0
- package/dist/ui/up/index.js.map +1 -0
- package/dist/ui/up/types.d.ts +1 -0
- package/dist/ui/up/types.js +2 -0
- package/dist/ui/up/types.js.map +1 -0
- package/dist/ui/up/up-app.d.ts +2 -0
- package/dist/ui/up/up-app.js +2 -0
- package/dist/ui/up/up-app.js.map +1 -0
- package/dist/ui/up/up-app.test.d.ts +1 -0
- package/dist/ui/up/up-app.test.js +266 -0
- package/dist/ui/up/up-app.test.js.map +1 -0
- package/dist/ui/up/utils.d.ts +19 -0
- package/dist/ui/up/utils.js +141 -0
- package/dist/ui/up/utils.js.map +1 -0
- package/dist/ui/up/views.d.ts +9 -0
- package/dist/ui/up/views.js +61 -0
- package/dist/ui/up/views.js.map +1 -0
- package/package.json +6 -2
package/dist/commands/up.test.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { EventEmitter } from 'node:events';
|
|
2
2
|
import { PassThrough } from 'node:stream';
|
|
3
|
-
import { describe, expect, it, vi } from 'vitest';
|
|
3
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
4
4
|
import { upCommand } from './up.js';
|
|
5
5
|
import { ApiClientError } from '../lib/api-client.js';
|
|
6
6
|
function createFakeChildProcess() {
|
|
@@ -12,6 +12,10 @@ function createFakeChildProcess() {
|
|
|
12
12
|
}
|
|
13
13
|
function createProcessRef() {
|
|
14
14
|
const listeners = new Map();
|
|
15
|
+
const stdout = new PassThrough();
|
|
16
|
+
const stderr = new PassThrough();
|
|
17
|
+
vi.spyOn(stdout, 'write').mockImplementation(() => true);
|
|
18
|
+
vi.spyOn(stderr, 'write').mockImplementation(() => true);
|
|
15
19
|
const processRef = {
|
|
16
20
|
once: vi.fn((event, handler) => {
|
|
17
21
|
listeners.set(event, handler);
|
|
@@ -24,27 +28,176 @@ function createProcessRef() {
|
|
|
24
28
|
return processRef;
|
|
25
29
|
}),
|
|
26
30
|
exit: vi.fn(),
|
|
27
|
-
stderr
|
|
28
|
-
|
|
29
|
-
},
|
|
31
|
+
stderr,
|
|
32
|
+
stdout,
|
|
30
33
|
};
|
|
31
34
|
return {
|
|
32
35
|
processRef,
|
|
33
36
|
listeners,
|
|
34
37
|
};
|
|
35
38
|
}
|
|
39
|
+
function createRuntimeControllerStub() {
|
|
40
|
+
const listeners = new Set();
|
|
41
|
+
const startupSteps = [
|
|
42
|
+
{ id: 'authenticating', label: 'Authenticating...', status: 'pending' },
|
|
43
|
+
{ id: 'reserving-subdomain', label: 'Reserving subdomain...', status: 'pending' },
|
|
44
|
+
{ id: 'connecting-edge', label: 'Connecting edge...', status: 'pending' },
|
|
45
|
+
{ id: 'verifying-local-target', label: 'Verifying local target...', status: 'pending' },
|
|
46
|
+
{ id: 'tunnel-live', label: 'Tunnel live.', status: 'pending' },
|
|
47
|
+
];
|
|
48
|
+
const snapshot = {
|
|
49
|
+
mode: 'startup',
|
|
50
|
+
phase: 'initializing',
|
|
51
|
+
startupSteps,
|
|
52
|
+
session: {
|
|
53
|
+
account: null,
|
|
54
|
+
clientEmail: null,
|
|
55
|
+
version: '0.3.0',
|
|
56
|
+
publicUrl: null,
|
|
57
|
+
localTarget: null,
|
|
58
|
+
protocol: 'HTTPS',
|
|
59
|
+
startedAtEpochMs: null,
|
|
60
|
+
tunnelId: null,
|
|
61
|
+
requestedSlug: null,
|
|
62
|
+
hostname: null,
|
|
63
|
+
},
|
|
64
|
+
health: {
|
|
65
|
+
statusLabel: 'Connecting',
|
|
66
|
+
region: null,
|
|
67
|
+
lastHeartbeatAtEpochMs: null,
|
|
68
|
+
cloudflaredConnected: false,
|
|
69
|
+
telemetryDisabled: false,
|
|
70
|
+
localTargetReachable: null,
|
|
71
|
+
lastLocalTargetCheckAtEpochMs: null,
|
|
72
|
+
},
|
|
73
|
+
metrics: {
|
|
74
|
+
ttl: 0,
|
|
75
|
+
opn: 0,
|
|
76
|
+
rt1Ms: null,
|
|
77
|
+
rt5Ms: null,
|
|
78
|
+
p50Ms: null,
|
|
79
|
+
p90Ms: null,
|
|
80
|
+
requestCountTotal: 0,
|
|
81
|
+
},
|
|
82
|
+
lastRequest: null,
|
|
83
|
+
requestLogs: [],
|
|
84
|
+
cloudflaredLogs: [],
|
|
85
|
+
notices: [],
|
|
86
|
+
verbose: false,
|
|
87
|
+
};
|
|
88
|
+
const emit = () => {
|
|
89
|
+
for (const listener of listeners) {
|
|
90
|
+
listener(snapshot);
|
|
91
|
+
}
|
|
92
|
+
};
|
|
93
|
+
const controller = {
|
|
94
|
+
setActions: vi.fn((actions) => {
|
|
95
|
+
controller.actions = actions;
|
|
96
|
+
}),
|
|
97
|
+
actions: null,
|
|
98
|
+
subscribe: vi.fn((listener) => {
|
|
99
|
+
listeners.add(listener);
|
|
100
|
+
listener(snapshot);
|
|
101
|
+
return () => {
|
|
102
|
+
listeners.delete(listener);
|
|
103
|
+
};
|
|
104
|
+
}),
|
|
105
|
+
getSnapshot: vi.fn(() => snapshot),
|
|
106
|
+
setStartupStep: vi.fn((stepId, status) => {
|
|
107
|
+
const step = snapshot.startupSteps.find((item) => item.id === stepId);
|
|
108
|
+
if (step) {
|
|
109
|
+
step.status = status;
|
|
110
|
+
}
|
|
111
|
+
emit();
|
|
112
|
+
}),
|
|
113
|
+
setAllStartupStepsPending: vi.fn(() => {
|
|
114
|
+
for (const step of snapshot.startupSteps) {
|
|
115
|
+
step.status = 'pending';
|
|
116
|
+
}
|
|
117
|
+
emit();
|
|
118
|
+
}),
|
|
119
|
+
setPhase: vi.fn((phase) => {
|
|
120
|
+
snapshot.phase = phase;
|
|
121
|
+
emit();
|
|
122
|
+
}),
|
|
123
|
+
setStatusLabel: vi.fn((label) => {
|
|
124
|
+
snapshot.health.statusLabel = label;
|
|
125
|
+
emit();
|
|
126
|
+
}),
|
|
127
|
+
setRegion: vi.fn((region) => {
|
|
128
|
+
snapshot.health.region = region;
|
|
129
|
+
emit();
|
|
130
|
+
}),
|
|
131
|
+
setCloudflaredConnected: vi.fn((connected) => {
|
|
132
|
+
snapshot.health.cloudflaredConnected = connected;
|
|
133
|
+
emit();
|
|
134
|
+
}),
|
|
135
|
+
setLocalTargetReachable: vi.fn((reachable, epochMs = Date.now()) => {
|
|
136
|
+
snapshot.health.localTargetReachable = reachable;
|
|
137
|
+
snapshot.health.lastLocalTargetCheckAtEpochMs = epochMs;
|
|
138
|
+
emit();
|
|
139
|
+
}),
|
|
140
|
+
setMetrics: vi.fn((metrics) => {
|
|
141
|
+
snapshot.metrics = {
|
|
142
|
+
...metrics,
|
|
143
|
+
requestCountTotal: snapshot.metrics.requestCountTotal,
|
|
144
|
+
};
|
|
145
|
+
emit();
|
|
146
|
+
}),
|
|
147
|
+
addRequest: vi.fn((event) => {
|
|
148
|
+
snapshot.lastRequest = event;
|
|
149
|
+
snapshot.requestLogs.push(event);
|
|
150
|
+
snapshot.metrics.requestCountTotal += 1;
|
|
151
|
+
emit();
|
|
152
|
+
}),
|
|
153
|
+
addCloudflaredLine: vi.fn((line) => {
|
|
154
|
+
snapshot.cloudflaredLogs.push(line);
|
|
155
|
+
emit();
|
|
156
|
+
}),
|
|
157
|
+
setTelemetryDisabled: vi.fn((disabled) => {
|
|
158
|
+
snapshot.health.telemetryDisabled = disabled;
|
|
159
|
+
emit();
|
|
160
|
+
}),
|
|
161
|
+
recordHeartbeat: vi.fn((epochMs = Date.now()) => {
|
|
162
|
+
snapshot.health.lastHeartbeatAtEpochMs = epochMs;
|
|
163
|
+
emit();
|
|
164
|
+
}),
|
|
165
|
+
setSession: vi.fn((patch) => {
|
|
166
|
+
Object.assign(snapshot.session, patch);
|
|
167
|
+
emit();
|
|
168
|
+
}),
|
|
169
|
+
addNotice: vi.fn((level, message) => {
|
|
170
|
+
snapshot.notices.push({ kind: level, message });
|
|
171
|
+
emit();
|
|
172
|
+
}),
|
|
173
|
+
markLive: vi.fn((epochMs = Date.now()) => {
|
|
174
|
+
snapshot.phase = 'live';
|
|
175
|
+
snapshot.mode = snapshot.mode === 'logs' ? 'logs' : 'running';
|
|
176
|
+
snapshot.session.startedAtEpochMs ??= epochMs;
|
|
177
|
+
snapshot.health.statusLabel = 'Healthy';
|
|
178
|
+
emit();
|
|
179
|
+
}),
|
|
180
|
+
dispose: vi.fn(),
|
|
181
|
+
};
|
|
182
|
+
return controller;
|
|
183
|
+
}
|
|
36
184
|
describe('upCommand', () => {
|
|
37
|
-
|
|
185
|
+
afterEach(() => {
|
|
186
|
+
vi.restoreAllMocks();
|
|
187
|
+
});
|
|
188
|
+
it('ships telemetry with sanitized paths and probes the local target before going live', async () => {
|
|
38
189
|
const child = createFakeChildProcess();
|
|
39
190
|
const proxyStop = vi.fn(async () => { });
|
|
40
|
-
const
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
191
|
+
const controller = createRuntimeControllerStub();
|
|
192
|
+
const { processRef } = createProcessRef();
|
|
193
|
+
let onRequest;
|
|
194
|
+
const startLocalProxy = vi.fn(async (input) => {
|
|
195
|
+
onRequest = input.onRequest;
|
|
196
|
+
return {
|
|
197
|
+
port: 4545,
|
|
198
|
+
stop: proxyStop,
|
|
199
|
+
};
|
|
200
|
+
});
|
|
48
201
|
const apiClient = {
|
|
49
202
|
refreshTokens: vi.fn(),
|
|
50
203
|
createTunnel: vi.fn(async () => ({
|
|
@@ -59,22 +212,22 @@ describe('upCommand', () => {
|
|
|
59
212
|
heartbeat: vi.fn(async () => ({ expiresAt: '2026-01-01T00:00:00.000Z' })),
|
|
60
213
|
ingestTelemetry: vi.fn(async () => { }),
|
|
61
214
|
};
|
|
62
|
-
const { processRef } = createProcessRef();
|
|
63
|
-
let proxyInput;
|
|
64
|
-
const startLocalProxy = vi.fn(async (input) => {
|
|
65
|
-
proxyInput = input;
|
|
66
|
-
return {
|
|
67
|
-
port: 4545,
|
|
68
|
-
stop: proxyStop,
|
|
69
|
-
};
|
|
70
|
-
});
|
|
71
215
|
const intervalCallbacks = [];
|
|
72
216
|
const setIntervalFn = vi.fn((fn, ms) => {
|
|
73
217
|
intervalCallbacks.push({ fn, ms });
|
|
74
218
|
return intervalCallbacks.length;
|
|
75
219
|
});
|
|
220
|
+
const clearIntervalFn = vi.fn();
|
|
221
|
+
const openUrl = vi.fn(async () => { });
|
|
222
|
+
const copyTextToClipboard = vi.fn(async () => { });
|
|
223
|
+
const verifyLocalTarget = vi.fn(async () => ({
|
|
224
|
+
ok: true,
|
|
225
|
+
status: 200,
|
|
226
|
+
error: null,
|
|
227
|
+
}));
|
|
228
|
+
const createApiClient = vi.fn(() => apiClient);
|
|
76
229
|
const runPromise = upCommand({ port: 3000, url: 'demo', verbose: false }, {
|
|
77
|
-
createApiClient:
|
|
230
|
+
createApiClient: createApiClient,
|
|
78
231
|
requireSession: vi.fn(async () => ({
|
|
79
232
|
accessToken: 'access',
|
|
80
233
|
refreshToken: 'refresh',
|
|
@@ -87,19 +240,41 @@ describe('upCommand', () => {
|
|
|
87
240
|
})),
|
|
88
241
|
saveSession: vi.fn(async () => { }),
|
|
89
242
|
ensureCloudflaredInstalled: vi.fn(async () => '/usr/local/bin/cloudflared'),
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
243
|
+
probeLocalTarget: verifyLocalTarget,
|
|
244
|
+
copyToClipboard: copyTextToClipboard,
|
|
245
|
+
openUrl,
|
|
246
|
+
startLocalProxy: startLocalProxy,
|
|
247
|
+
createUpRuntimeController: vi.fn(() => controller),
|
|
248
|
+
renderUpApp: vi.fn(() => ({ stop: vi.fn(async () => { }) })),
|
|
249
|
+
isInteractiveTerminal: vi.fn(() => false),
|
|
250
|
+
getCliVersion: vi.fn(() => '0.3.0'),
|
|
93
251
|
spawn: vi.fn(() => child),
|
|
94
252
|
processRef: processRef,
|
|
95
253
|
setInterval: setIntervalFn,
|
|
96
|
-
clearInterval:
|
|
254
|
+
clearInterval: clearIntervalFn,
|
|
97
255
|
});
|
|
98
256
|
await new Promise((resolve) => setImmediate(resolve));
|
|
99
|
-
|
|
257
|
+
expect(startLocalProxy).toHaveBeenCalledWith(expect.objectContaining({
|
|
258
|
+
targetPort: 3000,
|
|
259
|
+
}));
|
|
260
|
+
expect(apiClient.createTunnel).toHaveBeenCalledWith('access', expect.objectContaining({
|
|
261
|
+
port: 4545,
|
|
262
|
+
requestedSlug: 'demo',
|
|
263
|
+
}));
|
|
264
|
+
expect(verifyLocalTarget).toHaveBeenCalledWith({
|
|
265
|
+
port: 3000,
|
|
266
|
+
});
|
|
267
|
+
expect(controller.setSession).toHaveBeenCalledWith(expect.objectContaining({
|
|
268
|
+
publicUrl: 'https://demo.tunnel.example.com',
|
|
269
|
+
localTarget: 'http://localhost:3000',
|
|
270
|
+
tunnelId: 'tunnel-id',
|
|
271
|
+
clientEmail: 'osama@example.com',
|
|
272
|
+
}));
|
|
273
|
+
const requestHandler = onRequest;
|
|
274
|
+
if (!requestHandler) {
|
|
100
275
|
throw new Error('Expected onRequest callback to be registered.');
|
|
101
276
|
}
|
|
102
|
-
|
|
277
|
+
requestHandler({
|
|
103
278
|
startedAtEpochMs: 1700000000000,
|
|
104
279
|
method: 'GET',
|
|
105
280
|
path: '/foo/bar?token=secret',
|
|
@@ -110,7 +285,7 @@ describe('upCommand', () => {
|
|
|
110
285
|
error: false,
|
|
111
286
|
protocol: 'http',
|
|
112
287
|
});
|
|
113
|
-
const telemetryInterval = intervalCallbacks.find((interval) => interval.ms ===
|
|
288
|
+
const telemetryInterval = intervalCallbacks.find((interval) => interval.ms === 2_000);
|
|
114
289
|
if (!telemetryInterval) {
|
|
115
290
|
throw new Error('Expected telemetry interval to be registered.');
|
|
116
291
|
}
|
|
@@ -118,23 +293,18 @@ describe('upCommand', () => {
|
|
|
118
293
|
await new Promise((resolve) => setImmediate(resolve));
|
|
119
294
|
expect(apiClient.ingestTelemetry).toHaveBeenCalledTimes(1);
|
|
120
295
|
const payload = apiClient.ingestTelemetry.mock.calls[0]?.[2];
|
|
121
|
-
expect(payload.metrics.requests).toBe(1);
|
|
122
|
-
expect(payload.metrics.errors).toBe(0);
|
|
123
|
-
expect(payload.metrics.bytes).toBe(42);
|
|
124
296
|
expect(payload.requests[0]?.path).toBe('/foo/bar');
|
|
297
|
+
expect(controller.addCloudflaredLine).not.toHaveBeenCalled();
|
|
125
298
|
child.emit('exit', 0);
|
|
126
299
|
await runPromise;
|
|
300
|
+
expect(proxyStop).toHaveBeenCalledTimes(1);
|
|
301
|
+
expect(apiClient.stopTunnel).toHaveBeenCalledWith('access', 'tunnel-id');
|
|
302
|
+
expect(clearIntervalFn).toHaveBeenCalled();
|
|
127
303
|
});
|
|
128
|
-
it('disables telemetry when
|
|
304
|
+
it('disables telemetry when the portal rejects it and records a warning notice', async () => {
|
|
129
305
|
const child = createFakeChildProcess();
|
|
130
|
-
const
|
|
131
|
-
|
|
132
|
-
setMetrics: vi.fn(),
|
|
133
|
-
addRequest: vi.fn(),
|
|
134
|
-
addCloudflaredLine: vi.fn(),
|
|
135
|
-
addMessage: vi.fn(),
|
|
136
|
-
stop: vi.fn(),
|
|
137
|
-
};
|
|
306
|
+
const controller = createRuntimeControllerStub();
|
|
307
|
+
const { processRef } = createProcessRef();
|
|
138
308
|
const apiClient = {
|
|
139
309
|
refreshTokens: vi.fn(),
|
|
140
310
|
createTunnel: vi.fn(async () => ({
|
|
@@ -151,7 +321,6 @@ describe('upCommand', () => {
|
|
|
151
321
|
throw new ApiClientError(404, 'NOT_FOUND', 'missing');
|
|
152
322
|
}),
|
|
153
323
|
};
|
|
154
|
-
const { processRef } = createProcessRef();
|
|
155
324
|
const intervalCallbacks = [];
|
|
156
325
|
const setIntervalFn = vi.fn((fn, ms) => {
|
|
157
326
|
intervalCallbacks.push({ fn, ms });
|
|
@@ -159,7 +328,7 @@ describe('upCommand', () => {
|
|
|
159
328
|
});
|
|
160
329
|
const clearIntervalFn = vi.fn();
|
|
161
330
|
const runPromise = upCommand({ port: 3000, url: 'demo', verbose: false }, {
|
|
162
|
-
createApiClient: () => apiClient,
|
|
331
|
+
createApiClient: vi.fn(() => apiClient),
|
|
163
332
|
requireSession: vi.fn(async () => ({
|
|
164
333
|
accessToken: 'access',
|
|
165
334
|
refreshToken: 'refresh',
|
|
@@ -172,39 +341,48 @@ describe('upCommand', () => {
|
|
|
172
341
|
})),
|
|
173
342
|
saveSession: vi.fn(async () => { }),
|
|
174
343
|
ensureCloudflaredInstalled: vi.fn(async () => '/usr/local/bin/cloudflared'),
|
|
344
|
+
probeLocalTarget: vi.fn(async () => ({ ok: true, status: 200, error: null })),
|
|
345
|
+
copyToClipboard: vi.fn(async () => { }),
|
|
346
|
+
openUrl: vi.fn(async () => { }),
|
|
175
347
|
startLocalProxy: vi.fn(async () => ({
|
|
176
348
|
port: 4545,
|
|
177
349
|
stop: vi.fn(async () => { }),
|
|
178
350
|
})),
|
|
179
|
-
|
|
180
|
-
|
|
351
|
+
createUpRuntimeController: vi.fn(() => controller),
|
|
352
|
+
renderUpApp: vi.fn(() => ({ stop: vi.fn(async () => { }) })),
|
|
353
|
+
isInteractiveTerminal: vi.fn(() => false),
|
|
354
|
+
getCliVersion: vi.fn(() => '0.3.0'),
|
|
181
355
|
spawn: vi.fn(() => child),
|
|
182
356
|
processRef: processRef,
|
|
183
357
|
setInterval: setIntervalFn,
|
|
184
358
|
clearInterval: clearIntervalFn,
|
|
185
359
|
});
|
|
186
360
|
await new Promise((resolve) => setImmediate(resolve));
|
|
187
|
-
const telemetryInterval = intervalCallbacks.find((interval) => interval.ms ===
|
|
361
|
+
const telemetryInterval = intervalCallbacks.find((interval) => interval.ms === 2_000);
|
|
188
362
|
if (!telemetryInterval) {
|
|
189
363
|
throw new Error('Expected telemetry interval to be registered.');
|
|
190
364
|
}
|
|
191
365
|
telemetryInterval.fn();
|
|
192
366
|
await new Promise((resolve) => setImmediate(resolve));
|
|
367
|
+
expect(controller.setTelemetryDisabled).toHaveBeenCalledWith(true);
|
|
368
|
+
expect(controller.addNotice).toHaveBeenCalledWith('warning', 'Portal telemetry disabled (server does not support it).');
|
|
193
369
|
expect(clearIntervalFn).toHaveBeenCalled();
|
|
194
|
-
expect(dashboard.addMessage).toHaveBeenCalledWith(expect.stringMatching(/telemetry disabled/i));
|
|
195
370
|
child.emit('exit', 0);
|
|
196
371
|
await runPromise;
|
|
197
372
|
});
|
|
198
|
-
it('
|
|
373
|
+
it('stops cloudflared before stopping the remote tunnel when local target verification fails', async () => {
|
|
199
374
|
const child = createFakeChildProcess();
|
|
200
|
-
const
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
375
|
+
const controller = createRuntimeControllerStub();
|
|
376
|
+
const proxyStop = vi.fn(async () => { });
|
|
377
|
+
const { processRef } = createProcessRef();
|
|
378
|
+
child.kill = vi.fn((signal) => {
|
|
379
|
+
if (signal === 'SIGTERM' || signal === 'SIGKILL') {
|
|
380
|
+
setImmediate(() => {
|
|
381
|
+
child.emit('exit', signal === 'SIGKILL' ? 137 : 0, signal ?? null);
|
|
382
|
+
});
|
|
383
|
+
}
|
|
384
|
+
return true;
|
|
385
|
+
});
|
|
208
386
|
const apiClient = {
|
|
209
387
|
refreshTokens: vi.fn(),
|
|
210
388
|
createTunnel: vi.fn(async () => ({
|
|
@@ -216,20 +394,11 @@ describe('upCommand', () => {
|
|
|
216
394
|
leaseTimeoutSec: 60,
|
|
217
395
|
})),
|
|
218
396
|
stopTunnel: vi.fn(async () => { }),
|
|
219
|
-
heartbeat: vi.fn(async () => {
|
|
220
|
-
throw new ApiClientError(500, 'INTERNAL_ERROR', 'Unexpected server error');
|
|
221
|
-
}),
|
|
397
|
+
heartbeat: vi.fn(async () => ({ expiresAt: '2026-01-01T00:00:00.000Z' })),
|
|
222
398
|
ingestTelemetry: vi.fn(async () => { }),
|
|
223
399
|
};
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
const setIntervalFn = vi.fn((fn, ms) => {
|
|
227
|
-
intervalCallbacks.push({ fn, ms });
|
|
228
|
-
return intervalCallbacks.length;
|
|
229
|
-
});
|
|
230
|
-
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => { });
|
|
231
|
-
const runPromise = upCommand({ port: 3000, url: 'demo', verbose: false }, {
|
|
232
|
-
createApiClient: () => apiClient,
|
|
400
|
+
await expect(upCommand({ port: 3000, url: 'demo', verbose: false }, {
|
|
401
|
+
createApiClient: vi.fn(() => apiClient),
|
|
233
402
|
requireSession: vi.fn(async () => ({
|
|
234
403
|
accessToken: 'access',
|
|
235
404
|
refreshToken: 'refresh',
|
|
@@ -242,41 +411,35 @@ describe('upCommand', () => {
|
|
|
242
411
|
})),
|
|
243
412
|
saveSession: vi.fn(async () => { }),
|
|
244
413
|
ensureCloudflaredInstalled: vi.fn(async () => '/usr/local/bin/cloudflared'),
|
|
414
|
+
probeLocalTarget: vi.fn(async () => ({
|
|
415
|
+
ok: false,
|
|
416
|
+
status: null,
|
|
417
|
+
error: 'Local target did not respond within 5s.',
|
|
418
|
+
})),
|
|
419
|
+
copyToClipboard: vi.fn(async () => { }),
|
|
420
|
+
openUrl: vi.fn(async () => { }),
|
|
245
421
|
startLocalProxy: vi.fn(async () => ({
|
|
246
422
|
port: 4545,
|
|
247
|
-
stop:
|
|
423
|
+
stop: proxyStop,
|
|
248
424
|
})),
|
|
249
|
-
|
|
250
|
-
|
|
425
|
+
createUpRuntimeController: vi.fn(() => controller),
|
|
426
|
+
renderUpApp: vi.fn(() => ({ stop: vi.fn(async () => { }) })),
|
|
427
|
+
isInteractiveTerminal: vi.fn(() => false),
|
|
428
|
+
getCliVersion: vi.fn(() => '0.3.0'),
|
|
251
429
|
spawn: vi.fn(() => child),
|
|
252
430
|
processRef: processRef,
|
|
253
|
-
setInterval:
|
|
431
|
+
setInterval: vi.fn(() => 1),
|
|
254
432
|
clearInterval: vi.fn(),
|
|
255
|
-
});
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
}
|
|
261
|
-
heartbeatInterval.fn();
|
|
262
|
-
await new Promise((resolve) => setImmediate(resolve));
|
|
263
|
-
expect(apiClient.heartbeat).toHaveBeenCalledWith('run-token', 'tunnel-id');
|
|
264
|
-
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('Heartbeat failed (status=500, code=INTERNAL_ERROR): Unexpected server error'));
|
|
265
|
-
errorSpy.mockRestore();
|
|
266
|
-
child.emit('exit', 0);
|
|
267
|
-
await runPromise;
|
|
433
|
+
})).rejects.toThrow('Local target did not respond within 5s.');
|
|
434
|
+
expect(child.kill).toHaveBeenCalledWith('SIGTERM');
|
|
435
|
+
expect(apiClient.stopTunnel).toHaveBeenCalledWith('access', 'tunnel-id');
|
|
436
|
+
expect(proxyStop).toHaveBeenCalledTimes(1);
|
|
437
|
+
expect(child.kill.mock.invocationCallOrder[0]).toBeLessThan(apiClient.stopTunnel.mock.invocationCallOrder[0] ?? Number.POSITIVE_INFINITY);
|
|
268
438
|
});
|
|
269
|
-
it('
|
|
439
|
+
it('captures verbose cloudflared lines and derives the region from them', async () => {
|
|
270
440
|
const child = createFakeChildProcess();
|
|
271
|
-
const
|
|
272
|
-
const
|
|
273
|
-
setRegion: vi.fn(),
|
|
274
|
-
setMetrics: vi.fn(),
|
|
275
|
-
addRequest: vi.fn(),
|
|
276
|
-
addCloudflaredLine: vi.fn(),
|
|
277
|
-
addMessage: vi.fn(),
|
|
278
|
-
stop: vi.fn(),
|
|
279
|
-
};
|
|
441
|
+
const controller = createRuntimeControllerStub();
|
|
442
|
+
const { processRef } = createProcessRef();
|
|
280
443
|
const apiClient = {
|
|
281
444
|
refreshTokens: vi.fn(),
|
|
282
445
|
createTunnel: vi.fn(async () => ({
|
|
@@ -289,21 +452,18 @@ describe('upCommand', () => {
|
|
|
289
452
|
})),
|
|
290
453
|
stopTunnel: vi.fn(async () => { }),
|
|
291
454
|
heartbeat: vi.fn(async () => ({ expiresAt: '2026-01-01T00:00:00.000Z' })),
|
|
455
|
+
ingestTelemetry: vi.fn(async () => { }),
|
|
292
456
|
};
|
|
293
|
-
const { processRef } = createProcessRef();
|
|
294
|
-
const startLocalProxy = vi.fn(async () => ({
|
|
295
|
-
port: 4545,
|
|
296
|
-
stop: proxyStop,
|
|
297
|
-
}));
|
|
298
|
-
const createUpDashboard = vi.fn(() => dashboard);
|
|
299
457
|
const spawnFn = vi.fn(() => {
|
|
300
458
|
setImmediate(() => {
|
|
459
|
+
child.stderr.write('location=iad\n');
|
|
460
|
+
child.stderr.end();
|
|
301
461
|
child.emit('exit', 0);
|
|
302
462
|
});
|
|
303
463
|
return child;
|
|
304
464
|
});
|
|
305
|
-
|
|
306
|
-
createApiClient: () => apiClient,
|
|
465
|
+
const runPromise = upCommand({ port: 3000, verbose: true }, {
|
|
466
|
+
createApiClient: vi.fn(() => apiClient),
|
|
307
467
|
requireSession: vi.fn(async () => ({
|
|
308
468
|
accessToken: 'access',
|
|
309
469
|
refreshToken: 'refresh',
|
|
@@ -316,108 +476,30 @@ describe('upCommand', () => {
|
|
|
316
476
|
})),
|
|
317
477
|
saveSession: vi.fn(async () => { }),
|
|
318
478
|
ensureCloudflaredInstalled: vi.fn(async () => '/usr/local/bin/cloudflared'),
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
479
|
+
probeLocalTarget: vi.fn(async () => ({ ok: true, status: 200, error: null })),
|
|
480
|
+
copyToClipboard: vi.fn(async () => { }),
|
|
481
|
+
openUrl: vi.fn(async () => { }),
|
|
482
|
+
startLocalProxy: vi.fn(async () => ({
|
|
483
|
+
port: 4545,
|
|
484
|
+
stop: vi.fn(async () => { }),
|
|
485
|
+
})),
|
|
486
|
+
createUpRuntimeController: vi.fn(() => controller),
|
|
487
|
+
renderUpApp: vi.fn(() => ({ stop: vi.fn(async () => { }) })),
|
|
488
|
+
isInteractiveTerminal: vi.fn(() => false),
|
|
489
|
+
getCliVersion: vi.fn(() => '0.3.0'),
|
|
322
490
|
spawn: spawnFn,
|
|
323
491
|
processRef: processRef,
|
|
324
492
|
setInterval: vi.fn(() => 1),
|
|
325
493
|
clearInterval: vi.fn(),
|
|
326
494
|
});
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
expect(apiClient.createTunnel).toHaveBeenCalledWith('access', expect.objectContaining({
|
|
331
|
-
port: 4545,
|
|
332
|
-
requestedSlug: 'demo',
|
|
333
|
-
}));
|
|
334
|
-
expect(createUpDashboard).toHaveBeenCalledWith({
|
|
335
|
-
account: 'osama@example.com',
|
|
336
|
-
version: '0.1.0',
|
|
337
|
-
forwarding: 'https://demo.tunnel.example.com -> http://localhost:3000',
|
|
338
|
-
verbose: false,
|
|
339
|
-
});
|
|
340
|
-
expect(dashboard.setMetrics).toHaveBeenCalled();
|
|
341
|
-
expect(processRef.exit.mock.calls[0]?.[0]).toBe(0);
|
|
342
|
-
expect(proxyStop).toHaveBeenCalledTimes(1);
|
|
343
|
-
expect(apiClient.stopTunnel).toHaveBeenCalledWith('access', 'tunnel-id');
|
|
344
|
-
expect(spawnFn).toHaveBeenCalled();
|
|
345
|
-
});
|
|
346
|
-
it('emits raw cloudflared lines only when verbose mode is enabled', async () => {
|
|
347
|
-
const runCase = async (verbose) => {
|
|
348
|
-
const child = createFakeChildProcess();
|
|
349
|
-
const dashboard = {
|
|
350
|
-
setRegion: vi.fn(),
|
|
351
|
-
setMetrics: vi.fn(),
|
|
352
|
-
addRequest: vi.fn(),
|
|
353
|
-
addCloudflaredLine: vi.fn(),
|
|
354
|
-
addMessage: vi.fn(),
|
|
355
|
-
stop: vi.fn(),
|
|
356
|
-
};
|
|
357
|
-
const apiClient = {
|
|
358
|
-
refreshTokens: vi.fn(),
|
|
359
|
-
createTunnel: vi.fn(async () => ({
|
|
360
|
-
tunnelId: 'tunnel-id',
|
|
361
|
-
hostname: 'demo.tunnel.example.com',
|
|
362
|
-
cloudflaredToken: 'cf-token',
|
|
363
|
-
tunnelRunToken: 'run-token',
|
|
364
|
-
heartbeatIntervalSec: 20,
|
|
365
|
-
leaseTimeoutSec: 60,
|
|
366
|
-
})),
|
|
367
|
-
stopTunnel: vi.fn(async () => { }),
|
|
368
|
-
heartbeat: vi.fn(async () => ({ expiresAt: '2026-01-01T00:00:00.000Z' })),
|
|
369
|
-
};
|
|
370
|
-
const { processRef } = createProcessRef();
|
|
371
|
-
const spawnFn = vi.fn(() => {
|
|
372
|
-
setImmediate(() => {
|
|
373
|
-
child.stderr.write('location=iad\n');
|
|
374
|
-
child.stderr.end();
|
|
375
|
-
child.emit('exit', 0);
|
|
376
|
-
});
|
|
377
|
-
return child;
|
|
378
|
-
});
|
|
379
|
-
await upCommand({ port: 3000, verbose }, {
|
|
380
|
-
createApiClient: () => apiClient,
|
|
381
|
-
requireSession: vi.fn(async () => ({
|
|
382
|
-
accessToken: 'access',
|
|
383
|
-
refreshToken: 'refresh',
|
|
384
|
-
expiresAtEpochSec: 1,
|
|
385
|
-
profile: {
|
|
386
|
-
email: 'osama@example.com',
|
|
387
|
-
slackUserId: 'U1',
|
|
388
|
-
slackTeamId: 'TRIPESEED',
|
|
389
|
-
},
|
|
390
|
-
})),
|
|
391
|
-
saveSession: vi.fn(async () => { }),
|
|
392
|
-
ensureCloudflaredInstalled: vi.fn(async () => '/usr/local/bin/cloudflared'),
|
|
393
|
-
startLocalProxy: vi.fn(async () => ({
|
|
394
|
-
port: 4545,
|
|
395
|
-
stop: vi.fn(async () => { }),
|
|
396
|
-
})),
|
|
397
|
-
createUpDashboard: vi.fn(() => dashboard),
|
|
398
|
-
getCliVersion: vi.fn(() => '0.1.0'),
|
|
399
|
-
spawn: spawnFn,
|
|
400
|
-
processRef: processRef,
|
|
401
|
-
setInterval: vi.fn(() => 1),
|
|
402
|
-
clearInterval: vi.fn(),
|
|
403
|
-
});
|
|
404
|
-
expect(dashboard.setRegion).toHaveBeenCalledWith('IAD');
|
|
405
|
-
return dashboard.addCloudflaredLine.mock.calls.length;
|
|
406
|
-
};
|
|
407
|
-
expect(await runCase(false)).toBe(0);
|
|
408
|
-
expect(await runCase(true)).toBeGreaterThan(0);
|
|
495
|
+
await runPromise;
|
|
496
|
+
expect(controller.addCloudflaredLine).toHaveBeenCalledWith('location=iad');
|
|
497
|
+
expect(controller.setRegion).toHaveBeenCalledWith('IAD');
|
|
409
498
|
});
|
|
410
|
-
it('
|
|
499
|
+
it('reports heartbeat errors', async () => {
|
|
411
500
|
const child = createFakeChildProcess();
|
|
412
|
-
const
|
|
413
|
-
const
|
|
414
|
-
setRegion: vi.fn(),
|
|
415
|
-
setMetrics: vi.fn(),
|
|
416
|
-
addRequest: vi.fn(),
|
|
417
|
-
addCloudflaredLine: vi.fn(),
|
|
418
|
-
addMessage: vi.fn(),
|
|
419
|
-
stop: vi.fn(),
|
|
420
|
-
};
|
|
501
|
+
const controller = createRuntimeControllerStub();
|
|
502
|
+
const { processRef } = createProcessRef();
|
|
421
503
|
const apiClient = {
|
|
422
504
|
refreshTokens: vi.fn(),
|
|
423
505
|
createTunnel: vi.fn(async () => ({
|
|
@@ -429,17 +511,19 @@ describe('upCommand', () => {
|
|
|
429
511
|
leaseTimeoutSec: 60,
|
|
430
512
|
})),
|
|
431
513
|
stopTunnel: vi.fn(async () => { }),
|
|
432
|
-
heartbeat: vi.fn(async () =>
|
|
514
|
+
heartbeat: vi.fn(async () => {
|
|
515
|
+
throw new ApiClientError(500, 'INTERNAL_ERROR', 'Unexpected server error');
|
|
516
|
+
}),
|
|
517
|
+
ingestTelemetry: vi.fn(async () => { }),
|
|
433
518
|
};
|
|
434
|
-
const
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
});
|
|
439
|
-
return true;
|
|
519
|
+
const intervalCallbacks = [];
|
|
520
|
+
const setIntervalFn = vi.fn((fn, ms) => {
|
|
521
|
+
intervalCallbacks.push({ fn, ms });
|
|
522
|
+
return intervalCallbacks.length;
|
|
440
523
|
});
|
|
524
|
+
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => { });
|
|
441
525
|
const runPromise = upCommand({ port: 3000, verbose: false }, {
|
|
442
|
-
createApiClient: () => apiClient,
|
|
526
|
+
createApiClient: vi.fn(() => apiClient),
|
|
443
527
|
requireSession: vi.fn(async () => ({
|
|
444
528
|
accessToken: 'access',
|
|
445
529
|
refreshToken: 'refresh',
|
|
@@ -452,41 +536,38 @@ describe('upCommand', () => {
|
|
|
452
536
|
})),
|
|
453
537
|
saveSession: vi.fn(async () => { }),
|
|
454
538
|
ensureCloudflaredInstalled: vi.fn(async () => '/usr/local/bin/cloudflared'),
|
|
539
|
+
probeLocalTarget: vi.fn(async () => ({ ok: true, status: 200, error: null })),
|
|
540
|
+
copyToClipboard: vi.fn(async () => { }),
|
|
541
|
+
openUrl: vi.fn(async () => { }),
|
|
455
542
|
startLocalProxy: vi.fn(async () => ({
|
|
456
543
|
port: 4545,
|
|
457
|
-
stop:
|
|
544
|
+
stop: vi.fn(async () => { }),
|
|
458
545
|
})),
|
|
459
|
-
|
|
460
|
-
|
|
546
|
+
createUpRuntimeController: vi.fn(() => controller),
|
|
547
|
+
renderUpApp: vi.fn(() => ({ stop: vi.fn(async () => { }) })),
|
|
548
|
+
isInteractiveTerminal: vi.fn(() => false),
|
|
549
|
+
getCliVersion: vi.fn(() => '0.3.0'),
|
|
461
550
|
spawn: vi.fn(() => child),
|
|
462
551
|
processRef: processRef,
|
|
463
|
-
setInterval:
|
|
552
|
+
setInterval: setIntervalFn,
|
|
464
553
|
clearInterval: vi.fn(),
|
|
465
554
|
});
|
|
466
555
|
await new Promise((resolve) => setImmediate(resolve));
|
|
467
|
-
const
|
|
468
|
-
if (!
|
|
469
|
-
throw new Error('Expected
|
|
556
|
+
const heartbeatInterval = intervalCallbacks.find((interval) => interval.ms === 20_000);
|
|
557
|
+
if (!heartbeatInterval) {
|
|
558
|
+
throw new Error('Expected heartbeat interval to be registered.');
|
|
470
559
|
}
|
|
471
|
-
|
|
560
|
+
heartbeatInterval.fn();
|
|
561
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
562
|
+
expect(apiClient.heartbeat).toHaveBeenCalledWith('run-token', 'tunnel-id');
|
|
563
|
+
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('Heartbeat failed (status=500, code=INTERNAL_ERROR): Unexpected server error'));
|
|
564
|
+
child.emit('exit', 0);
|
|
472
565
|
await runPromise;
|
|
473
|
-
expect(child.kill).toHaveBeenCalledWith('SIGINT');
|
|
474
|
-
expect(apiClient.stopTunnel).toHaveBeenCalledTimes(1);
|
|
475
|
-
expect(child.kill.mock.invocationCallOrder[0] ?? 0).toBeLessThan(apiClient.stopTunnel.mock.invocationCallOrder[0] ?? Number.MAX_SAFE_INTEGER);
|
|
476
|
-
expect(proxyStop).toHaveBeenCalledTimes(1);
|
|
477
|
-
expect(dashboard.stop).toHaveBeenCalledTimes(1);
|
|
478
|
-
expect(processRef.exit.mock.calls[0]?.[0]).toBe(130);
|
|
479
566
|
});
|
|
480
567
|
it('does not overlap heartbeats while one is still in flight', async () => {
|
|
481
568
|
const child = createFakeChildProcess();
|
|
482
|
-
const
|
|
483
|
-
|
|
484
|
-
setMetrics: vi.fn(),
|
|
485
|
-
addRequest: vi.fn(),
|
|
486
|
-
addCloudflaredLine: vi.fn(),
|
|
487
|
-
addMessage: vi.fn(),
|
|
488
|
-
stop: vi.fn(),
|
|
489
|
-
};
|
|
569
|
+
const controller = createRuntimeControllerStub();
|
|
570
|
+
const { processRef } = createProcessRef();
|
|
490
571
|
let resolveHeartbeat;
|
|
491
572
|
const heartbeatPromise = new Promise((resolve) => {
|
|
492
573
|
resolveHeartbeat = () => resolve({ expiresAt: '2026-01-01T00:00:00.000Z' });
|
|
@@ -505,14 +586,13 @@ describe('upCommand', () => {
|
|
|
505
586
|
heartbeat: vi.fn(() => heartbeatPromise),
|
|
506
587
|
ingestTelemetry: vi.fn(async () => { }),
|
|
507
588
|
};
|
|
508
|
-
const { processRef } = createProcessRef();
|
|
509
589
|
const intervalCallbacks = [];
|
|
510
590
|
const setIntervalFn = vi.fn((fn, ms) => {
|
|
511
591
|
intervalCallbacks.push({ fn, ms });
|
|
512
592
|
return intervalCallbacks.length;
|
|
513
593
|
});
|
|
514
594
|
const runPromise = upCommand({ port: 3000, verbose: false }, {
|
|
515
|
-
createApiClient: () => apiClient,
|
|
595
|
+
createApiClient: vi.fn(() => apiClient),
|
|
516
596
|
requireSession: vi.fn(async () => ({
|
|
517
597
|
accessToken: 'access',
|
|
518
598
|
refreshToken: 'refresh',
|
|
@@ -525,12 +605,17 @@ describe('upCommand', () => {
|
|
|
525
605
|
})),
|
|
526
606
|
saveSession: vi.fn(async () => { }),
|
|
527
607
|
ensureCloudflaredInstalled: vi.fn(async () => '/usr/local/bin/cloudflared'),
|
|
608
|
+
probeLocalTarget: vi.fn(async () => ({ ok: true, status: 200, error: null })),
|
|
609
|
+
copyToClipboard: vi.fn(async () => { }),
|
|
610
|
+
openUrl: vi.fn(async () => { }),
|
|
528
611
|
startLocalProxy: vi.fn(async () => ({
|
|
529
612
|
port: 4545,
|
|
530
613
|
stop: vi.fn(async () => { }),
|
|
531
614
|
})),
|
|
532
|
-
|
|
533
|
-
|
|
615
|
+
createUpRuntimeController: vi.fn(() => controller),
|
|
616
|
+
renderUpApp: vi.fn(() => ({ stop: vi.fn(async () => { }) })),
|
|
617
|
+
isInteractiveTerminal: vi.fn(() => false),
|
|
618
|
+
getCliVersion: vi.fn(() => '0.3.0'),
|
|
534
619
|
spawn: vi.fn(() => child),
|
|
535
620
|
processRef: processRef,
|
|
536
621
|
setInterval: setIntervalFn,
|
|
@@ -555,5 +640,276 @@ describe('upCommand', () => {
|
|
|
555
640
|
child.emit('exit', 0);
|
|
556
641
|
await runPromise;
|
|
557
642
|
});
|
|
643
|
+
it('routes shortcuts through controller actions and restarts only cloudflared', async () => {
|
|
644
|
+
const child1 = createFakeChildProcess();
|
|
645
|
+
const child2 = createFakeChildProcess();
|
|
646
|
+
const controller = createRuntimeControllerStub();
|
|
647
|
+
const { processRef } = createProcessRef();
|
|
648
|
+
const openUrl = vi.fn(async () => { });
|
|
649
|
+
const copyTextToClipboard = vi.fn(async () => { });
|
|
650
|
+
const proxyStop = vi.fn(async () => { });
|
|
651
|
+
child1.kill = vi.fn((signal) => {
|
|
652
|
+
setImmediate(() => {
|
|
653
|
+
child1.emit('exit', signal === 'SIGINT' ? 130 : 0);
|
|
654
|
+
});
|
|
655
|
+
return true;
|
|
656
|
+
});
|
|
657
|
+
child2.kill = vi.fn((signal) => {
|
|
658
|
+
setImmediate(() => {
|
|
659
|
+
child2.emit('exit', signal === 'SIGINT' ? 130 : 0);
|
|
660
|
+
});
|
|
661
|
+
return true;
|
|
662
|
+
});
|
|
663
|
+
const apiClient = {
|
|
664
|
+
refreshTokens: vi.fn(),
|
|
665
|
+
createTunnel: vi.fn(async () => ({
|
|
666
|
+
tunnelId: 'tunnel-id',
|
|
667
|
+
hostname: 'demo.tunnel.example.com',
|
|
668
|
+
cloudflaredToken: 'cf-token',
|
|
669
|
+
tunnelRunToken: 'run-token',
|
|
670
|
+
heartbeatIntervalSec: 20,
|
|
671
|
+
leaseTimeoutSec: 60,
|
|
672
|
+
})),
|
|
673
|
+
stopTunnel: vi.fn(async () => { }),
|
|
674
|
+
heartbeat: vi.fn(async () => ({ expiresAt: '2026-01-01T00:00:00.000Z' })),
|
|
675
|
+
ingestTelemetry: vi.fn(async () => { }),
|
|
676
|
+
};
|
|
677
|
+
const spawnFn = vi.fn()
|
|
678
|
+
.mockImplementationOnce(() => child1)
|
|
679
|
+
.mockImplementationOnce(() => child2);
|
|
680
|
+
const runPromise = upCommand({ port: 3000, url: 'demo', verbose: false }, {
|
|
681
|
+
createApiClient: vi.fn(() => apiClient),
|
|
682
|
+
requireSession: vi.fn(async () => ({
|
|
683
|
+
accessToken: 'access',
|
|
684
|
+
refreshToken: 'refresh',
|
|
685
|
+
expiresAtEpochSec: 1,
|
|
686
|
+
profile: {
|
|
687
|
+
email: 'osama@example.com',
|
|
688
|
+
slackUserId: 'U1',
|
|
689
|
+
slackTeamId: 'TRIPESEED',
|
|
690
|
+
},
|
|
691
|
+
})),
|
|
692
|
+
saveSession: vi.fn(async () => { }),
|
|
693
|
+
ensureCloudflaredInstalled: vi.fn(async () => '/usr/local/bin/cloudflared'),
|
|
694
|
+
probeLocalTarget: vi.fn(async () => ({ ok: true, status: 200, error: null })),
|
|
695
|
+
copyToClipboard: copyTextToClipboard,
|
|
696
|
+
openUrl,
|
|
697
|
+
startLocalProxy: vi.fn(async () => ({
|
|
698
|
+
port: 4545,
|
|
699
|
+
stop: proxyStop,
|
|
700
|
+
})),
|
|
701
|
+
createUpRuntimeController: vi.fn(() => controller),
|
|
702
|
+
renderUpApp: vi.fn(() => ({ stop: vi.fn(async () => { }) })),
|
|
703
|
+
isInteractiveTerminal: vi.fn(() => false),
|
|
704
|
+
getCliVersion: vi.fn(() => '0.3.0'),
|
|
705
|
+
spawn: spawnFn,
|
|
706
|
+
processRef: processRef,
|
|
707
|
+
setInterval: vi.fn(() => 1),
|
|
708
|
+
clearInterval: vi.fn(),
|
|
709
|
+
});
|
|
710
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
711
|
+
if (!controller.actions) {
|
|
712
|
+
throw new Error('Expected controller actions to be registered.');
|
|
713
|
+
}
|
|
714
|
+
await controller.actions.openBrowser();
|
|
715
|
+
await controller.actions.copyUrl();
|
|
716
|
+
expect(openUrl).toHaveBeenCalledWith('https://demo.tunnel.example.com');
|
|
717
|
+
expect(copyTextToClipboard).toHaveBeenCalledWith('https://demo.tunnel.example.com');
|
|
718
|
+
const restartPromise = controller.actions.restartCloudflared();
|
|
719
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
720
|
+
await restartPromise;
|
|
721
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
722
|
+
expect(child1.kill).toHaveBeenCalledWith('SIGTERM');
|
|
723
|
+
expect(apiClient.createTunnel).toHaveBeenCalledTimes(1);
|
|
724
|
+
expect(spawnFn).toHaveBeenCalledTimes(2);
|
|
725
|
+
expect(spawnFn.mock.calls[0]?.[1]).toEqual(['tunnel', '--no-autoupdate', 'run', '--token', 'cf-token']);
|
|
726
|
+
expect(spawnFn.mock.calls[1]?.[1]).toEqual(['tunnel', '--no-autoupdate', 'run', '--token', 'cf-token']);
|
|
727
|
+
child2.emit('exit', 0);
|
|
728
|
+
await runPromise;
|
|
729
|
+
expect(proxyStop).toHaveBeenCalledTimes(1);
|
|
730
|
+
});
|
|
731
|
+
it('cleans up resources and exits on SIGINT', async () => {
|
|
732
|
+
const child = createFakeChildProcess();
|
|
733
|
+
const controller = createRuntimeControllerStub();
|
|
734
|
+
const proxyStop = vi.fn(async () => { });
|
|
735
|
+
const { processRef, listeners } = createProcessRef();
|
|
736
|
+
child.kill = vi.fn((signal) => {
|
|
737
|
+
setImmediate(() => {
|
|
738
|
+
child.emit('exit', signal === 'SIGINT' ? 130 : 0);
|
|
739
|
+
});
|
|
740
|
+
return true;
|
|
741
|
+
});
|
|
742
|
+
const apiClient = {
|
|
743
|
+
refreshTokens: vi.fn(),
|
|
744
|
+
createTunnel: vi.fn(async () => ({
|
|
745
|
+
tunnelId: 'tunnel-id',
|
|
746
|
+
hostname: 'demo.tunnel.example.com',
|
|
747
|
+
cloudflaredToken: 'cf-token',
|
|
748
|
+
tunnelRunToken: 'run-token',
|
|
749
|
+
heartbeatIntervalSec: 20,
|
|
750
|
+
leaseTimeoutSec: 60,
|
|
751
|
+
})),
|
|
752
|
+
stopTunnel: vi.fn(async () => { }),
|
|
753
|
+
heartbeat: vi.fn(async () => ({ expiresAt: '2026-01-01T00:00:00.000Z' })),
|
|
754
|
+
ingestTelemetry: vi.fn(async () => { }),
|
|
755
|
+
};
|
|
756
|
+
const runPromise = upCommand({ port: 3000, verbose: false }, {
|
|
757
|
+
createApiClient: vi.fn(() => apiClient),
|
|
758
|
+
requireSession: vi.fn(async () => ({
|
|
759
|
+
accessToken: 'access',
|
|
760
|
+
refreshToken: 'refresh',
|
|
761
|
+
expiresAtEpochSec: 1,
|
|
762
|
+
profile: {
|
|
763
|
+
email: 'osama@example.com',
|
|
764
|
+
slackUserId: 'U1',
|
|
765
|
+
slackTeamId: 'TRIPESEED',
|
|
766
|
+
},
|
|
767
|
+
})),
|
|
768
|
+
saveSession: vi.fn(async () => { }),
|
|
769
|
+
ensureCloudflaredInstalled: vi.fn(async () => '/usr/local/bin/cloudflared'),
|
|
770
|
+
probeLocalTarget: vi.fn(async () => ({ ok: true, status: 200, error: null })),
|
|
771
|
+
copyToClipboard: vi.fn(async () => { }),
|
|
772
|
+
openUrl: vi.fn(async () => { }),
|
|
773
|
+
startLocalProxy: vi.fn(async () => ({
|
|
774
|
+
port: 4545,
|
|
775
|
+
stop: proxyStop,
|
|
776
|
+
})),
|
|
777
|
+
createUpRuntimeController: vi.fn(() => controller),
|
|
778
|
+
renderUpApp: vi.fn(() => ({ stop: vi.fn(async () => { }) })),
|
|
779
|
+
isInteractiveTerminal: vi.fn(() => false),
|
|
780
|
+
getCliVersion: vi.fn(() => '0.3.0'),
|
|
781
|
+
spawn: vi.fn(() => child),
|
|
782
|
+
processRef: processRef,
|
|
783
|
+
setInterval: vi.fn(() => 1),
|
|
784
|
+
clearInterval: vi.fn(),
|
|
785
|
+
});
|
|
786
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
787
|
+
const sigintHandler = listeners.get('SIGINT');
|
|
788
|
+
if (!sigintHandler) {
|
|
789
|
+
throw new Error('Expected SIGINT handler to be registered.');
|
|
790
|
+
}
|
|
791
|
+
sigintHandler();
|
|
792
|
+
await runPromise;
|
|
793
|
+
expect(child.kill).toHaveBeenCalledWith('SIGINT');
|
|
794
|
+
expect(apiClient.stopTunnel).toHaveBeenCalledWith('access', 'tunnel-id');
|
|
795
|
+
expect(proxyStop).toHaveBeenCalledTimes(1);
|
|
796
|
+
expect(processRef.exit.mock.calls[0]?.[0]).toBe(130);
|
|
797
|
+
});
|
|
798
|
+
it('treats quit as a shutdown request before cloudflared starts', async () => {
|
|
799
|
+
let resolveProxyStart;
|
|
800
|
+
const proxyStop = vi.fn(async () => { });
|
|
801
|
+
const controller = createRuntimeControllerStub();
|
|
802
|
+
const { processRef } = createProcessRef();
|
|
803
|
+
const runPromise = upCommand({ port: 3000, verbose: false }, {
|
|
804
|
+
createApiClient: vi.fn(() => ({
|
|
805
|
+
refreshTokens: vi.fn(),
|
|
806
|
+
createTunnel: vi.fn(async () => ({
|
|
807
|
+
tunnelId: 'tunnel-id',
|
|
808
|
+
hostname: 'demo.tunnel.example.com',
|
|
809
|
+
cloudflaredToken: 'cf-token',
|
|
810
|
+
tunnelRunToken: 'run-token',
|
|
811
|
+
heartbeatIntervalSec: 20,
|
|
812
|
+
leaseTimeoutSec: 60,
|
|
813
|
+
})),
|
|
814
|
+
stopTunnel: vi.fn(async () => { }),
|
|
815
|
+
heartbeat: vi.fn(async () => ({ expiresAt: '2026-01-01T00:00:00.000Z' })),
|
|
816
|
+
ingestTelemetry: vi.fn(async () => { }),
|
|
817
|
+
})),
|
|
818
|
+
requireSession: vi.fn(async () => ({
|
|
819
|
+
accessToken: 'access',
|
|
820
|
+
refreshToken: 'refresh',
|
|
821
|
+
expiresAtEpochSec: 1,
|
|
822
|
+
profile: {
|
|
823
|
+
email: 'osama@example.com',
|
|
824
|
+
slackUserId: 'U1',
|
|
825
|
+
slackTeamId: 'TRIPESEED',
|
|
826
|
+
},
|
|
827
|
+
})),
|
|
828
|
+
saveSession: vi.fn(async () => { }),
|
|
829
|
+
ensureCloudflaredInstalled: vi.fn(async () => '/usr/local/bin/cloudflared'),
|
|
830
|
+
probeLocalTarget: vi.fn(async () => ({ ok: true, status: 200, error: null })),
|
|
831
|
+
copyToClipboard: vi.fn(async () => { }),
|
|
832
|
+
openUrl: vi.fn(async () => { }),
|
|
833
|
+
startLocalProxy: vi.fn(() => new Promise((resolve) => {
|
|
834
|
+
resolveProxyStart = resolve;
|
|
835
|
+
})),
|
|
836
|
+
createUpRuntimeController: vi.fn(() => controller),
|
|
837
|
+
renderUpApp: vi.fn(() => ({ stop: vi.fn(async () => { }) })),
|
|
838
|
+
isInteractiveTerminal: vi.fn(() => false),
|
|
839
|
+
getCliVersion: vi.fn(() => '0.3.0'),
|
|
840
|
+
spawn: vi.fn(),
|
|
841
|
+
processRef: processRef,
|
|
842
|
+
setInterval: vi.fn(() => 1),
|
|
843
|
+
clearInterval: vi.fn(),
|
|
844
|
+
});
|
|
845
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
846
|
+
if (!controller.actions) {
|
|
847
|
+
throw new Error('Expected controller actions to be registered.');
|
|
848
|
+
}
|
|
849
|
+
expect(() => controller.actions?.quit()).not.toThrow();
|
|
850
|
+
if (!resolveProxyStart) {
|
|
851
|
+
throw new Error('Expected local proxy startup to be pending.');
|
|
852
|
+
}
|
|
853
|
+
resolveProxyStart({
|
|
854
|
+
port: 4545,
|
|
855
|
+
stop: proxyStop,
|
|
856
|
+
});
|
|
857
|
+
await runPromise;
|
|
858
|
+
expect(proxyStop).toHaveBeenCalledTimes(1);
|
|
859
|
+
expect(processRef.exit.mock.calls[0]?.[0]).toBe(130);
|
|
860
|
+
});
|
|
861
|
+
it('bypasses Ink rendering in non-interactive terminals', async () => {
|
|
862
|
+
const child = createFakeChildProcess();
|
|
863
|
+
const controller = createRuntimeControllerStub();
|
|
864
|
+
const renderUpApp = vi.fn(() => ({ stop: vi.fn(async () => { }) }));
|
|
865
|
+
const proxyStop = vi.fn(async () => { });
|
|
866
|
+
const runPromise = upCommand({ port: 3000, verbose: false }, {
|
|
867
|
+
createApiClient: vi.fn(() => ({
|
|
868
|
+
refreshTokens: vi.fn(),
|
|
869
|
+
createTunnel: vi.fn(async () => ({
|
|
870
|
+
tunnelId: 'tunnel-id',
|
|
871
|
+
hostname: 'demo.tunnel.example.com',
|
|
872
|
+
cloudflaredToken: 'cf-token',
|
|
873
|
+
tunnelRunToken: 'run-token',
|
|
874
|
+
heartbeatIntervalSec: 20,
|
|
875
|
+
leaseTimeoutSec: 60,
|
|
876
|
+
})),
|
|
877
|
+
stopTunnel: vi.fn(async () => { }),
|
|
878
|
+
heartbeat: vi.fn(async () => ({ expiresAt: '2026-01-01T00:00:00.000Z' })),
|
|
879
|
+
ingestTelemetry: vi.fn(async () => { }),
|
|
880
|
+
})),
|
|
881
|
+
requireSession: vi.fn(async () => ({
|
|
882
|
+
accessToken: 'access',
|
|
883
|
+
refreshToken: 'refresh',
|
|
884
|
+
expiresAtEpochSec: 1,
|
|
885
|
+
profile: {
|
|
886
|
+
email: 'osama@example.com',
|
|
887
|
+
slackUserId: 'U1',
|
|
888
|
+
slackTeamId: 'TRIPESEED',
|
|
889
|
+
},
|
|
890
|
+
})),
|
|
891
|
+
saveSession: vi.fn(async () => { }),
|
|
892
|
+
ensureCloudflaredInstalled: vi.fn(async () => '/usr/local/bin/cloudflared'),
|
|
893
|
+
probeLocalTarget: vi.fn(async () => ({ ok: true, status: 200, error: null })),
|
|
894
|
+
copyToClipboard: vi.fn(async () => { }),
|
|
895
|
+
openUrl: vi.fn(async () => { }),
|
|
896
|
+
startLocalProxy: vi.fn(async () => ({
|
|
897
|
+
port: 4545,
|
|
898
|
+
stop: proxyStop,
|
|
899
|
+
})),
|
|
900
|
+
createUpRuntimeController: vi.fn(() => controller),
|
|
901
|
+
renderUpApp,
|
|
902
|
+
isInteractiveTerminal: vi.fn(() => false),
|
|
903
|
+
getCliVersion: vi.fn(() => '0.3.0'),
|
|
904
|
+
spawn: vi.fn(() => child),
|
|
905
|
+
processRef: createProcessRef().processRef,
|
|
906
|
+
setInterval: vi.fn(() => 1),
|
|
907
|
+
clearInterval: vi.fn(),
|
|
908
|
+
});
|
|
909
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
910
|
+
expect(renderUpApp).not.toHaveBeenCalled();
|
|
911
|
+
child.emit('exit', 0);
|
|
912
|
+
await runPromise;
|
|
913
|
+
});
|
|
558
914
|
});
|
|
559
915
|
//# sourceMappingURL=up.test.js.map
|