badgr-cli 1.0.32 → 1.0.34

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.
@@ -0,0 +1,326 @@
1
+ /**
2
+ * Launch readiness — gap-filling tests for features on the outreach checklist
3
+ *
4
+ * Covers items missing from run-lifecycle and serve-lifecycle:
5
+ * - badgr down → terminates, writes receipt, prints cost summary
6
+ * - badgr status → shows running deployments, rate, endpoint; empty state
7
+ * - compat_failure error → correct user-facing message for CUDA mismatch
8
+ * - failure_category surfaced → compat failure receipt shows failure reason
9
+ */
10
+
11
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
12
+
13
+ // ── Module mocks ──────────────────────────────────────────────────────────────
14
+
15
+ vi.mock('../src/api.js', () => ({
16
+ callApi: vi.fn(),
17
+ terminateDeployment: vi.fn(),
18
+ listDeployments: vi.fn(),
19
+ }));
20
+
21
+ vi.mock('../src/store.js', () => ({
22
+ addDeployment: vi.fn(),
23
+ addReceipt: vi.fn(),
24
+ updateReceipt: vi.fn(),
25
+ generateReceiptId: vi.fn(() => 'rcpt-down-001'),
26
+ generateDeploymentId: vi.fn(() => 'dep-down-001'),
27
+ listDeployments: vi.fn(() => []),
28
+ listReceipts: vi.fn(() => []),
29
+ findDeployment: vi.fn(() => null),
30
+ updateDeployment: vi.fn(),
31
+ removeDeployment: vi.fn(),
32
+ }));
33
+
34
+ vi.mock('../src/config.js', () => ({
35
+ requireApiKey: vi.fn(),
36
+ loadConfig: vi.fn(() => ({ apiKey: 'sk-test', baseUrl: 'https://api.test/v1' })),
37
+ saveConfig: vi.fn(),
38
+ }));
39
+
40
+ import { downCommand } from '../src/commands/down.js';
41
+ import { statusCommand } from '../src/commands/status.js';
42
+ import { callWithFallback, CapacityError } from '../src/fallback.js';
43
+ import * as api from '../src/api.js';
44
+ import * as store from '../src/store.js';
45
+
46
+ // ── Helpers ───────────────────────────────────────────────────────────────────
47
+
48
+ const config = { apiKey: 'sk-test', baseUrl: 'https://api.test/v1' };
49
+
50
+ const chalk = {
51
+ bold: s => s,
52
+ dim: s => s,
53
+ red: s => s,
54
+ yellow: s => s,
55
+ green: s => s,
56
+ cyan: s => s,
57
+ };
58
+
59
+ const NOW = 1_700_000_000; // fixed unix timestamp
60
+ const STARTED = NOW - 600; // 10 minutes ago
61
+
62
+ function makeTerminatedDep(overrides = {}) {
63
+ return {
64
+ deployment_id: 'dep-abc123',
65
+ gpu_type: 'RTX_4090',
66
+ cost_per_hour: 0.85,
67
+ started_at: STARTED,
68
+ stopped_at: NOW,
69
+ status: 'stopped',
70
+ ...overrides,
71
+ };
72
+ }
73
+
74
+ function makeRunningDep(overrides = {}) {
75
+ return {
76
+ deployment_id: 'dep-run-001',
77
+ workload_type: 'job',
78
+ gpu_type: 'A100',
79
+ gpu_count: 1,
80
+ cost_per_hour: 2.50,
81
+ status: 'running',
82
+ endpoint_url: null,
83
+ model: null,
84
+ ...overrides,
85
+ };
86
+ }
87
+
88
+ beforeEach(() => {
89
+ vi.spyOn(console, 'log').mockImplementation(() => {});
90
+ vi.spyOn(console, 'error').mockImplementation(() => {});
91
+ vi.spyOn(process.stdout, 'write').mockImplementation(() => true);
92
+ vi.clearAllMocks();
93
+ store.generateReceiptId.mockReturnValue('rcpt-down-001');
94
+ store.findDeployment.mockReturnValue(null);
95
+ });
96
+
97
+ afterEach(() => {
98
+ vi.restoreAllMocks();
99
+ });
100
+
101
+ // ─────────────────────────────────────────────────────────────────────────────
102
+ // 1. badgr down
103
+ // ─────────────────────────────────────────────────────────────────────────────
104
+
105
+ describe('badgr down', () => {
106
+ it('calls terminateDeployment with the provided deployment ID', async () => {
107
+ api.terminateDeployment.mockResolvedValue(makeTerminatedDep());
108
+
109
+ await downCommand(config, ['dep-abc123'], chalk);
110
+
111
+ expect(api.terminateDeployment).toHaveBeenCalledWith(config, 'dep-abc123');
112
+ });
113
+
114
+ it('writes a receipt with runtime, finalCost, and action=badgr down', async () => {
115
+ api.terminateDeployment.mockResolvedValue(makeTerminatedDep());
116
+
117
+ await downCommand(config, ['dep-abc123'], chalk);
118
+
119
+ expect(store.addReceipt).toHaveBeenCalledWith(expect.objectContaining({
120
+ action: 'badgr down',
121
+ deploymentId: 'dep-abc123',
122
+ gpu: 'RTX_4090',
123
+ status: 'terminated',
124
+ }));
125
+ // Runtime should be ~600s (10 min window)
126
+ const call = store.addReceipt.mock.calls[0][0];
127
+ expect(call.runtimeSeconds).toBeGreaterThan(0);
128
+ expect(call.finalCost).toBeGreaterThan(0);
129
+ });
130
+
131
+ it('removes the local deployment record', async () => {
132
+ api.terminateDeployment.mockResolvedValue(makeTerminatedDep());
133
+
134
+ await downCommand(config, ['dep-abc123'], chalk);
135
+
136
+ expect(store.removeDeployment).toHaveBeenCalledWith('dep-abc123');
137
+ });
138
+
139
+ it('resolves local name to deployment ID via findDeployment', async () => {
140
+ store.findDeployment.mockReturnValue({ id: 'dep-abc123', name: 'my-job' });
141
+ api.terminateDeployment.mockResolvedValue(makeTerminatedDep());
142
+
143
+ await downCommand(config, ['my-job'], chalk);
144
+
145
+ expect(api.terminateDeployment).toHaveBeenCalledWith(config, 'dep-abc123');
146
+ });
147
+
148
+ it('prints error and returns without crashing on API failure', async () => {
149
+ api.terminateDeployment.mockRejectedValue(new Error('network error'));
150
+
151
+ await downCommand(config, ['dep-abc123'], chalk);
152
+
153
+ expect(console.error).toHaveBeenCalledWith(
154
+ expect.stringContaining('Could not stop deployment'),
155
+ );
156
+ expect(store.addReceipt).not.toHaveBeenCalled();
157
+ });
158
+
159
+ it('prints usage hint when no deployment ID given', async () => {
160
+ await downCommand(config, [], chalk);
161
+
162
+ expect(console.error).toHaveBeenCalledWith(
163
+ expect.stringContaining('Usage'),
164
+ );
165
+ expect(api.terminateDeployment).not.toHaveBeenCalled();
166
+ });
167
+ });
168
+
169
+ // ─────────────────────────────────────────────────────────────────────────────
170
+ // 2. badgr status
171
+ // ─────────────────────────────────────────────────────────────────────────────
172
+
173
+ describe('badgr status', () => {
174
+ it('shows "Nothing running" when no active deployments', async () => {
175
+ api.listDeployments.mockResolvedValue({ deployments: [] });
176
+
177
+ await statusCommand(config, [], chalk);
178
+
179
+ const allOutput = console.log.mock.calls.flat().join(' ');
180
+ expect(allOutput).toContain('Nothing running');
181
+ });
182
+
183
+ it('displays running deployment with GPU type and billing rate', async () => {
184
+ api.listDeployments.mockResolvedValue({ deployments: [makeRunningDep()] });
185
+
186
+ await statusCommand(config, [], chalk);
187
+
188
+ const allOutput = console.log.mock.calls.flat().join(' ');
189
+ expect(allOutput).toContain('dep-run-001');
190
+ expect(allOutput).toContain('A100');
191
+ expect(allOutput).toContain('2.50');
192
+ });
193
+
194
+ it('shows endpoint URL for endpoint workloads', async () => {
195
+ api.listDeployments.mockResolvedValue({
196
+ deployments: [makeRunningDep({
197
+ workload_type: 'endpoint',
198
+ endpoint_url: 'https://dep-run-001.aibadgr.com/v1',
199
+ })],
200
+ });
201
+
202
+ await statusCommand(config, [], chalk);
203
+
204
+ const allOutput = console.log.mock.calls.flat().join(' ');
205
+ expect(allOutput).toContain('dep-run-001.aibadgr.com');
206
+ });
207
+
208
+ it('shows total billing rate when multiple deployments are running', async () => {
209
+ api.listDeployments.mockResolvedValue({
210
+ deployments: [
211
+ makeRunningDep({ deployment_id: 'dep-1', cost_per_hour: 1.20 }),
212
+ makeRunningDep({ deployment_id: 'dep-2', cost_per_hour: 2.50 }),
213
+ ],
214
+ });
215
+
216
+ await statusCommand(config, [], chalk);
217
+
218
+ const allOutput = console.log.mock.calls.flat().join(' ');
219
+ // Total: 1.20 + 2.50 = 3.70
220
+ expect(allOutput).toContain('3.70');
221
+ });
222
+
223
+ it('shows provisioning-state deployment as starting', async () => {
224
+ api.listDeployments.mockResolvedValue({
225
+ deployments: [makeRunningDep({ status: 'provisioning', cost_per_hour: 0 })],
226
+ });
227
+
228
+ await statusCommand(config, [], chalk);
229
+
230
+ const allOutput = console.log.mock.calls.flat().join(' ');
231
+ expect(allOutput).toContain('starting');
232
+ });
233
+
234
+ it('falls back to local store when API is unavailable', async () => {
235
+ api.listDeployments.mockRejectedValue(new Error('connection refused'));
236
+ store.listDeployments.mockReturnValue([
237
+ { id: 'dep-local-001', name: 'local-job', gpu: 'L40S', status: 'running', costPerHour: 1.10 },
238
+ ]);
239
+
240
+ await statusCommand(config, [], chalk);
241
+
242
+ const allOutput = console.log.mock.calls.flat().join(' ');
243
+ expect(allOutput).toContain('dep-local-001');
244
+ });
245
+ });
246
+
247
+ // ─────────────────────────────────────────────────────────────────────────────
248
+ // 3. CUDA / compat failure error messages
249
+ // ─────────────────────────────────────────────────────────────────────────────
250
+
251
+ describe('compat_failure error message', () => {
252
+ function makeCompatError() {
253
+ const err = new Error('PROVISIONING_FAILED');
254
+ err.errorData = {
255
+ code: 'PROVISIONING_FAILED',
256
+ failure_category: 'compat_failure',
257
+ };
258
+ return err;
259
+ }
260
+
261
+ it('shows GPU/CUDA incompatibility message when failure_category is compat_failure', async () => {
262
+ api.callApi.mockRejectedValue(makeCompatError());
263
+
264
+ let thrown;
265
+ try {
266
+ await callWithFallback(
267
+ '/run',
268
+ { apiKey: 'sk-test', baseUrl: 'https://api.test/v1' },
269
+ () => ({ gpu: 'RTX_4090' }),
270
+ '1',
271
+ chalk,
272
+ { thing: 'job', cmd: 'badgr run' },
273
+ );
274
+ } catch (err) {
275
+ thrown = err;
276
+ }
277
+
278
+ expect(thrown).toBeInstanceOf(CapacityError);
279
+ expect(thrown.message).toContain('incompatib');
280
+ });
281
+
282
+ it('shows generic provisioning failure when failure_category is infrastructure', async () => {
283
+ const err = new Error('PROVISIONING_FAILED');
284
+ err.errorData = { code: 'PROVISIONING_FAILED', failure_category: 'infrastructure' };
285
+ api.callApi.mockRejectedValue(err);
286
+
287
+ let thrown;
288
+ try {
289
+ await callWithFallback(
290
+ '/run',
291
+ { apiKey: 'sk-test', baseUrl: 'https://api.test/v1' },
292
+ () => ({}),
293
+ '1',
294
+ chalk,
295
+ { thing: 'job', cmd: 'badgr run' },
296
+ );
297
+ } catch (err2) {
298
+ thrown = err2;
299
+ }
300
+
301
+ expect(thrown).toBeInstanceOf(CapacityError);
302
+ // Should NOT mention CUDA/compat
303
+ expect(thrown.message).not.toContain('incompatib');
304
+ expect(thrown.message).toContain('failed to start');
305
+ });
306
+
307
+ it('compat message mentions trying a different GPU type or image', async () => {
308
+ api.callApi.mockRejectedValue(makeCompatError());
309
+
310
+ let thrown;
311
+ try {
312
+ await callWithFallback(
313
+ '/run',
314
+ { apiKey: 'sk-test', baseUrl: 'https://api.test/v1' },
315
+ () => ({}),
316
+ '2',
317
+ chalk,
318
+ { thing: 'job', cmd: 'badgr run' },
319
+ );
320
+ } catch (err) {
321
+ thrown = err;
322
+ }
323
+
324
+ expect(thrown.message).toMatch(/GPU type|image/i);
325
+ });
326
+ });
@@ -85,15 +85,25 @@ function setupSuccessfulRun(depOverrides = {}, exitCode = 0) {
85
85
  process.setMaxListeners(50);
86
86
 
87
87
  beforeEach(() => {
88
- vi.useFakeTimers();
88
+ // Explicitly list what to fake — vitest 4.x also fakes queueMicrotask and
89
+ // nextTick by default which deadlocks await-inside-while-loop patterns.
90
+ vi.useFakeTimers({
91
+ toFake: ['setTimeout', 'setInterval', 'clearTimeout', 'clearInterval', 'Date', 'performance', 'setImmediate', 'clearImmediate'],
92
+ });
89
93
  process.exitCode = undefined;
90
94
  vi.spyOn(console, 'log').mockImplementation(() => {});
91
95
  vi.spyOn(console, 'error').mockImplementation(() => {});
92
96
  vi.spyOn(process.stdout, 'write').mockImplementation(() => true);
93
- // Reset call counts between tests
94
- vi.clearAllMocks();
95
- // Restore default mock return values that clearAllMocks wipes
97
+ // resetAllMocks clears both call history AND the mockResolvedValueOnce queue
98
+ // (clearAllMocks only clears history in vitest 4.x, leaving stale queued values
99
+ // that can corrupt subsequent tests).
100
+ vi.resetAllMocks();
101
+ // Restore default mock return values cleared by resetAllMocks
96
102
  store.generateReceiptId.mockReturnValue('rcpt-launch-001');
103
+ store.generateDeploymentId.mockReturnValue('dep-launch-001');
104
+ store.listDeployments.mockReturnValue([]);
105
+ store.listReceipts.mockReturnValue([]);
106
+ store.findDeployment.mockReturnValue(null);
97
107
  api.terminateDeployment.mockResolvedValue({});
98
108
  });
99
109