@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.
Files changed (83) hide show
  1. package/dist/commands/doctor.js +34 -4
  2. package/dist/commands/doctor.js.map +1 -1
  3. package/dist/commands/doctor.test.d.ts +1 -0
  4. package/dist/commands/doctor.test.js +80 -0
  5. package/dist/commands/doctor.test.js.map +1 -0
  6. package/dist/commands/list.js +45 -7
  7. package/dist/commands/list.js.map +1 -1
  8. package/dist/commands/list.test.d.ts +1 -0
  9. package/dist/commands/list.test.js +84 -0
  10. package/dist/commands/list.test.js.map +1 -0
  11. package/dist/commands/login.js +102 -18
  12. package/dist/commands/login.js.map +1 -1
  13. package/dist/commands/login.test.js +109 -3
  14. package/dist/commands/login.test.js.map +1 -1
  15. package/dist/commands/logout.js +18 -1
  16. package/dist/commands/logout.js.map +1 -1
  17. package/dist/commands/logout.test.d.ts +1 -0
  18. package/dist/commands/logout.test.js +78 -0
  19. package/dist/commands/logout.test.js.map +1 -0
  20. package/dist/commands/stop.js +18 -1
  21. package/dist/commands/stop.js.map +1 -1
  22. package/dist/commands/stop.test.d.ts +1 -0
  23. package/dist/commands/stop.test.js +46 -0
  24. package/dist/commands/stop.test.js.map +1 -0
  25. package/dist/commands/up.d.ts +12 -2
  26. package/dist/commands/up.js +349 -74
  27. package/dist/commands/up.js.map +1 -1
  28. package/dist/commands/up.test.js +589 -233
  29. package/dist/commands/up.test.js.map +1 -1
  30. package/dist/lib/clipboard.d.ts +1 -0
  31. package/dist/lib/clipboard.js +50 -0
  32. package/dist/lib/clipboard.js.map +1 -0
  33. package/dist/lib/local-target.d.ts +12 -0
  34. package/dist/lib/local-target.js +48 -0
  35. package/dist/lib/local-target.js.map +1 -0
  36. package/dist/lib/local-target.test.d.ts +1 -0
  37. package/dist/lib/local-target.test.js +23 -0
  38. package/dist/lib/local-target.test.js.map +1 -0
  39. package/dist/lib/up-dashboard.test.js +141 -51
  40. package/dist/lib/up-dashboard.test.js.map +1 -1
  41. package/dist/lib/up-runtime.d.ts +110 -0
  42. package/dist/lib/up-runtime.js +455 -0
  43. package/dist/lib/up-runtime.js.map +1 -0
  44. package/dist/ui/index.d.ts +2 -0
  45. package/dist/ui/index.js +3 -0
  46. package/dist/ui/index.js.map +1 -0
  47. package/dist/ui/output.d.ts +20 -0
  48. package/dist/ui/output.js +50 -0
  49. package/dist/ui/output.js.map +1 -0
  50. package/dist/ui/output.test.d.ts +1 -0
  51. package/dist/ui/output.test.js +84 -0
  52. package/dist/ui/output.test.js.map +1 -0
  53. package/dist/ui/primitives.d.ts +50 -0
  54. package/dist/ui/primitives.js +102 -0
  55. package/dist/ui/primitives.js.map +1 -0
  56. package/dist/ui/primitives.test.d.ts +1 -0
  57. package/dist/ui/primitives.test.js +46 -0
  58. package/dist/ui/primitives.test.js.map +1 -0
  59. package/dist/ui/test-utils.d.ts +10 -0
  60. package/dist/ui/test-utils.js +71 -0
  61. package/dist/ui/test-utils.js.map +1 -0
  62. package/dist/ui/up/UpApp.d.ts +5 -0
  63. package/dist/ui/up/UpApp.js +47 -0
  64. package/dist/ui/up/UpApp.js.map +1 -0
  65. package/dist/ui/up/index.d.ts +4 -0
  66. package/dist/ui/up/index.js +3 -0
  67. package/dist/ui/up/index.js.map +1 -0
  68. package/dist/ui/up/types.d.ts +1 -0
  69. package/dist/ui/up/types.js +2 -0
  70. package/dist/ui/up/types.js.map +1 -0
  71. package/dist/ui/up/up-app.d.ts +2 -0
  72. package/dist/ui/up/up-app.js +2 -0
  73. package/dist/ui/up/up-app.js.map +1 -0
  74. package/dist/ui/up/up-app.test.d.ts +1 -0
  75. package/dist/ui/up/up-app.test.js +266 -0
  76. package/dist/ui/up/up-app.test.js.map +1 -0
  77. package/dist/ui/up/utils.d.ts +19 -0
  78. package/dist/ui/up/utils.js +141 -0
  79. package/dist/ui/up/utils.js.map +1 -0
  80. package/dist/ui/up/views.d.ts +9 -0
  81. package/dist/ui/up/views.js +61 -0
  82. package/dist/ui/up/views.js.map +1 -0
  83. package/package.json +6 -2
@@ -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
- write: vi.fn(),
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
- it('ships telemetry with sanitized paths', async () => {
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 dashboard = {
41
- setRegion: vi.fn(),
42
- setMetrics: vi.fn(),
43
- addRequest: vi.fn(),
44
- addCloudflaredLine: vi.fn(),
45
- addMessage: vi.fn(),
46
- stop: vi.fn(),
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: () => apiClient,
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
- startLocalProxy,
91
- createUpDashboard: vi.fn(() => dashboard),
92
- getCliVersion: vi.fn(() => '0.1.0'),
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: vi.fn(),
254
+ clearInterval: clearIntervalFn,
97
255
  });
98
256
  await new Promise((resolve) => setImmediate(resolve));
99
- if (!proxyInput?.onRequest) {
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
- proxyInput.onRequest({
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 === 2000);
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 server does not support it', async () => {
304
+ it('disables telemetry when the portal rejects it and records a warning notice', async () => {
129
305
  const child = createFakeChildProcess();
130
- const dashboard = {
131
- setRegion: vi.fn(),
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
- createUpDashboard: vi.fn(() => dashboard),
180
- getCliVersion: vi.fn(() => '0.1.0'),
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 === 2000);
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('prints heartbeat API status and code when heartbeat fails', async () => {
373
+ it('stops cloudflared before stopping the remote tunnel when local target verification fails', async () => {
199
374
  const child = createFakeChildProcess();
200
- const dashboard = {
201
- setRegion: vi.fn(),
202
- setMetrics: vi.fn(),
203
- addRequest: vi.fn(),
204
- addCloudflaredLine: vi.fn(),
205
- addMessage: vi.fn(),
206
- stop: vi.fn(),
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
- const { processRef } = createProcessRef();
225
- const intervalCallbacks = [];
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: vi.fn(async () => { }),
423
+ stop: proxyStop,
248
424
  })),
249
- createUpDashboard: vi.fn(() => dashboard),
250
- getCliVersion: vi.fn(() => '0.1.0'),
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: setIntervalFn,
431
+ setInterval: vi.fn(() => 1),
254
432
  clearInterval: vi.fn(),
255
- });
256
- await new Promise((resolve) => setImmediate(resolve));
257
- const heartbeatInterval = intervalCallbacks.find((interval) => interval.ms === 20_000);
258
- if (!heartbeatInterval) {
259
- throw new Error('Expected heartbeat interval to be registered.');
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('creates tunnel using local proxy port and initializes dashboard fields', async () => {
439
+ it('captures verbose cloudflared lines and derives the region from them', async () => {
270
440
  const child = createFakeChildProcess();
271
- const proxyStop = vi.fn(async () => { });
272
- const dashboard = {
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
- await upCommand({ port: 3000, url: 'demo', verbose: false }, {
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
- startLocalProxy,
320
- createUpDashboard,
321
- getCliVersion: vi.fn(() => '0.1.0'),
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
- expect(startLocalProxy).toHaveBeenCalledWith(expect.objectContaining({
328
- targetPort: 3000,
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('cleans up resources and kills child process on SIGINT', async () => {
499
+ it('reports heartbeat errors', async () => {
411
500
  const child = createFakeChildProcess();
412
- const proxyStop = vi.fn(async () => { });
413
- const dashboard = {
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 () => ({ expiresAt: '2026-01-01T00:00:00.000Z' })),
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 { processRef, listeners } = createProcessRef();
435
- child.kill = vi.fn((signal) => {
436
- setImmediate(() => {
437
- child.emit('exit', signal === 'SIGINT' ? 130 : 0);
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: proxyStop,
544
+ stop: vi.fn(async () => { }),
458
545
  })),
459
- createUpDashboard: vi.fn(() => dashboard),
460
- getCliVersion: vi.fn(() => '0.1.0'),
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: vi.fn(() => 1),
552
+ setInterval: setIntervalFn,
464
553
  clearInterval: vi.fn(),
465
554
  });
466
555
  await new Promise((resolve) => setImmediate(resolve));
467
- const sigintHandler = listeners.get('SIGINT');
468
- if (!sigintHandler) {
469
- throw new Error('Expected SIGINT handler to be registered.');
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
- sigintHandler();
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 dashboard = {
483
- setRegion: vi.fn(),
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
- createUpDashboard: vi.fn(() => dashboard),
533
- getCliVersion: vi.fn(() => '0.1.0'),
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