badgr-cli 1.0.30 → 1.0.32

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,498 @@
1
+ /**
2
+ * badgr run — end-to-end lifecycle tests (mocked API)
3
+ *
4
+ * Covers the launch-readiness matrix:
5
+ * - Successful run on two GPU types (A100, L40S) via two providers (runpod, vastai)
6
+ * - Job exits non-zero → receipt failureType = customer_code
7
+ * - Job exceeds max runtime → teardown called, receipt reason = max-runtime
8
+ * - Job exceeds max cost → teardown called, receipt reason = max-cost
9
+ * - Container fails before running (infrastructure)
10
+ * - Heartbeat lost mid-run → teardown called, receipt reason = heartbeat-lost
11
+ * - Log streaming → log lines appear in console output
12
+ * - Tier-1 unavailable → auto-expand to tier-2, receipt records tier
13
+ * - No capacity at all → CapacityError displayed, exitCode = 1
14
+ * - Billing stops → terminateDeployment called on every terminal path
15
+ * - Receipt explains state → status, exitCode, failureType, runtimeSeconds, finalCost
16
+ */
17
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
18
+ import { runCommand } from '../src/commands/run.js';
19
+
20
+ // ── Module mocks ──────────────────────────────────────────────────────────────
21
+
22
+ vi.mock('../src/api.js', () => ({
23
+ callApi: vi.fn(),
24
+ terminateDeployment: vi.fn().mockResolvedValue({}),
25
+ }));
26
+
27
+ vi.mock('../src/store.js', () => ({
28
+ addDeployment: vi.fn(),
29
+ addReceipt: vi.fn(),
30
+ updateReceipt: vi.fn(),
31
+ generateReceiptId: vi.fn(() => 'rcpt-launch-001'),
32
+ generateDeploymentId: vi.fn(() => 'dep-launch-001'),
33
+ listDeployments: vi.fn(() => []),
34
+ listReceipts: vi.fn(() => []),
35
+ findDeployment: vi.fn(() => null),
36
+ updateDeployment: vi.fn(),
37
+ removeDeployment: vi.fn(),
38
+ }));
39
+
40
+ // Imported after mock declarations so we get the mocked versions
41
+ import * as api from '../src/api.js';
42
+ import * as store from '../src/store.js';
43
+
44
+ // ── Helpers ───────────────────────────────────────────────────────────────────
45
+
46
+ const config = { apiKey: 'sk-test', baseUrl: 'https://api.test/v1' };
47
+
48
+ // Chalk no-op (the real chalk would add ANSI codes, which makes assertions noisy)
49
+ const chalk = {
50
+ bold: s => s,
51
+ dim: s => s,
52
+ red: s => s,
53
+ yellow: s => s,
54
+ green: s => s,
55
+ cyan: s => s,
56
+ };
57
+
58
+ function makeDep(overrides = {}) {
59
+ return {
60
+ deployment_id: 'dep-launch-001',
61
+ status: 'running',
62
+ gpu_type: 'A100',
63
+ gpu_count: 1,
64
+ cost_per_hour: 2.50,
65
+ provider: 'runpod',
66
+ receipt_id: 'rcpt-launch-001',
67
+ tier: '1',
68
+ ...overrides,
69
+ };
70
+ }
71
+
72
+ // Standard happy-path sequence: POST /run → GET /deployments → GET /logs → (no DELETE needed for success)
73
+ function setupSuccessfulRun(depOverrides = {}, exitCode = 0) {
74
+ api.callApi
75
+ .mockResolvedValueOnce(makeDep(depOverrides)) // POST /run
76
+ .mockResolvedValueOnce({ status: 'completed', exit_code: exitCode }) // GET /deployments/…
77
+ .mockResolvedValueOnce({ logs: ['step 1', 'step 2'] }); // GET /deployments/…/logs
78
+ api.terminateDeployment.mockResolvedValue({});
79
+ }
80
+
81
+ // ── Setup / teardown ─────────────────────────────────────────────────────────
82
+
83
+ // Suppress MaxListenersExceededWarning — each runCommand call adds a SIGINT listener
84
+ // and we run many tests in sequence.
85
+ process.setMaxListeners(50);
86
+
87
+ beforeEach(() => {
88
+ vi.useFakeTimers();
89
+ process.exitCode = undefined;
90
+ vi.spyOn(console, 'log').mockImplementation(() => {});
91
+ vi.spyOn(console, 'error').mockImplementation(() => {});
92
+ 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
96
+ store.generateReceiptId.mockReturnValue('rcpt-launch-001');
97
+ api.terminateDeployment.mockResolvedValue({});
98
+ });
99
+
100
+ afterEach(() => {
101
+ vi.useRealTimers();
102
+ vi.restoreAllMocks();
103
+ process.exitCode = undefined;
104
+ });
105
+
106
+ // ─────────────────────────────────────────────────────────────────────────────
107
+ // 1. GPU / provider matrix
108
+ // ─────────────────────────────────────────────────────────────────────────────
109
+
110
+ describe('GPU / provider matrix', () => {
111
+ it('A100 via RunPod (tier-1) completes successfully', async () => {
112
+ setupSuccessfulRun({ gpu_type: 'A100', provider: 'runpod', tier: '1' });
113
+ const p = runCommand(config, ['python', 'train.py', '--gpu', 'A100'], chalk);
114
+ await vi.advanceTimersByTimeAsync(5000);
115
+ await p;
116
+
117
+ expect(api.callApi).toHaveBeenCalledWith('/run', expect.objectContaining({ method: 'POST' }));
118
+ expect(store.addReceipt).toHaveBeenCalledWith(expect.objectContaining({
119
+ gpu: 'A100',
120
+ providerRoute: 'runpod',
121
+ tier: '1',
122
+ }));
123
+ expect(store.updateReceipt).toHaveBeenCalledWith('rcpt-launch-001', expect.objectContaining({
124
+ status: 'completed',
125
+ exitCode: 0,
126
+ }));
127
+ expect(process.exitCode).toBeFalsy();
128
+ });
129
+
130
+ it('L40S via Vast.ai (tier-1) completes successfully', async () => {
131
+ api.callApi
132
+ .mockResolvedValueOnce(makeDep({ gpu_type: 'L40S', provider: 'vastai', tier: '1', cost_per_hour: 1.80 }))
133
+ .mockResolvedValueOnce({ status: 'completed', exit_code: 0 })
134
+ .mockResolvedValueOnce({ logs: [] });
135
+ const p = runCommand(config, ['python', 'train.py', '--gpu', 'L40S'], chalk);
136
+ await vi.advanceTimersByTimeAsync(5000);
137
+ await p;
138
+
139
+ expect(store.addReceipt).toHaveBeenCalledWith(expect.objectContaining({
140
+ gpu: 'L40S',
141
+ providerRoute: 'vastai',
142
+ }));
143
+ expect(store.updateReceipt).toHaveBeenCalledWith('rcpt-launch-001', expect.objectContaining({
144
+ status: 'completed',
145
+ exitCode: 0,
146
+ }));
147
+ expect(process.exitCode).toBeFalsy();
148
+ });
149
+ });
150
+
151
+ // ─────────────────────────────────────────────────────────────────────────────
152
+ // 2. Job exits non-zero
153
+ // ─────────────────────────────────────────────────────────────────────────────
154
+
155
+ describe('non-zero exit code', () => {
156
+ it('records customer_code failure and sets exitCode', async () => {
157
+ api.callApi
158
+ .mockResolvedValueOnce(makeDep())
159
+ .mockResolvedValueOnce({ status: 'failed', exit_code: 1 })
160
+ .mockResolvedValueOnce({ logs: ['Traceback (most recent call last)'] });
161
+ const p = runCommand(config, ['python', 'train.py'], chalk);
162
+ await vi.advanceTimersByTimeAsync(5000);
163
+ await p;
164
+
165
+ expect(store.updateReceipt).toHaveBeenCalledWith('rcpt-launch-001', expect.objectContaining({
166
+ exitCode: 1,
167
+ failureType: 'customer_code',
168
+ }));
169
+ expect(process.exitCode).toBe(1);
170
+ });
171
+
172
+ it('distinguishes customer_code from infrastructure when exit code is null', async () => {
173
+ api.callApi
174
+ .mockResolvedValueOnce(makeDep())
175
+ .mockResolvedValueOnce({ status: 'failed', exit_code: null })
176
+ .mockResolvedValueOnce({ logs: [] });
177
+ const p = runCommand(config, ['python', 'train.py'], chalk);
178
+ await vi.advanceTimersByTimeAsync(5000);
179
+ await p;
180
+
181
+ expect(store.updateReceipt).toHaveBeenCalledWith('rcpt-launch-001', expect.objectContaining({
182
+ failureType: 'infrastructure',
183
+ }));
184
+ });
185
+ });
186
+
187
+ // ─────────────────────────────────────────────────────────────────────────────
188
+ // 3. Container fails to start (infrastructure failure before running)
189
+ // ─────────────────────────────────────────────────────────────────────────────
190
+
191
+ describe('container fails to start', () => {
192
+ it('reports infrastructure error when dep arrives already failed', async () => {
193
+ api.callApi
194
+ .mockResolvedValueOnce(makeDep({ status: 'failed' })) // POST /run returns already-failed
195
+ .mockResolvedValueOnce({ logs: [] });
196
+ const p = runCommand(config, ['python', 'train.py'], chalk);
197
+ await vi.advanceTimersByTimeAsync(1000);
198
+ await p;
199
+
200
+ // runCommand exits with process.exitCode = 1 when dep.status === 'failed' after provision
201
+ expect(process.exitCode).toBe(1);
202
+ });
203
+
204
+ it('reports infrastructure error when waitForRunning resolves to failed', async () => {
205
+ api.callApi
206
+ .mockResolvedValueOnce(makeDep({ status: 'provisioning' })) // POST /run → provisioning
207
+ .mockResolvedValueOnce({ status: 'failed' }); // poll → failed
208
+ const p = runCommand(config, ['python', 'train.py'], chalk);
209
+ // advance past POLL_MS (3000ms) in waitForRunning
210
+ await vi.advanceTimersByTimeAsync(4000);
211
+ await p;
212
+
213
+ expect(process.exitCode).toBe(1);
214
+ });
215
+ });
216
+
217
+ // ─────────────────────────────────────────────────────────────────────────────
218
+ // 4. Max-runtime cap
219
+ // ─────────────────────────────────────────────────────────────────────────────
220
+
221
+ describe('max-runtime cap', () => {
222
+ it('stops job and records max-runtime when runtime exceeded', async () => {
223
+ // max-runtime 0.05 min = 3 seconds; ratePerHour = 2.50
224
+ api.callApi
225
+ .mockResolvedValueOnce(makeDep({ cost_per_hour: 2.50 })) // POST /run
226
+ .mockResolvedValueOnce({ status: 'running', exit_code: null }) // poll 1
227
+ .mockResolvedValueOnce({ logs: [] }); // logs 1
228
+ api.terminateDeployment.mockResolvedValue({});
229
+
230
+ const p = runCommand(config, ['python', 'train.py', '--max-runtime', '0.05'], chalk);
231
+ // attachToJob POLL_MS = 4000, maxRuntime = 0.05 min = 3000ms → cap fires after 4000ms (first elapsedMs check)
232
+ await vi.advanceTimersByTimeAsync(5000);
233
+ await p;
234
+
235
+ expect(api.terminateDeployment).toHaveBeenCalledWith(config, 'dep-launch-001');
236
+ expect(store.updateReceipt).toHaveBeenCalledWith('rcpt-launch-001', expect.objectContaining({
237
+ status: 'max-runtime',
238
+ teardownStatus: 'terminated',
239
+ }));
240
+ expect(process.exitCode).toBe(1);
241
+ });
242
+ });
243
+
244
+ // ─────────────────────────────────────────────────────────────────────────────
245
+ // 5. Max-cost cap
246
+ // ─────────────────────────────────────────────────────────────────────────────
247
+
248
+ describe('max-cost cap', () => {
249
+ it('stops job and records max-cost when budget exceeded', async () => {
250
+ // ratePerHour = 3600 ($1/s), maxCost = 0.001 → cap fires after 3.6ms, but poll fires at 4000ms
251
+ api.callApi
252
+ .mockResolvedValueOnce(makeDep({ cost_per_hour: 3600 })) // POST /run
253
+ .mockResolvedValueOnce({ status: 'running' }) // poll 1 (never reached — cap fires first)
254
+ .mockResolvedValueOnce({ logs: [] });
255
+ api.terminateDeployment.mockResolvedValue({});
256
+
257
+ const p = runCommand(config, ['python', 'train.py', '--max-cost', '0.001'], chalk);
258
+ await vi.advanceTimersByTimeAsync(5000);
259
+ await p;
260
+
261
+ expect(api.terminateDeployment).toHaveBeenCalledWith(config, 'dep-launch-001');
262
+ expect(store.updateReceipt).toHaveBeenCalledWith('rcpt-launch-001', expect.objectContaining({
263
+ status: 'max-cost',
264
+ teardownStatus: 'terminated',
265
+ }));
266
+ expect(process.exitCode).toBe(1);
267
+ });
268
+ });
269
+
270
+ // ─────────────────────────────────────────────────────────────────────────────
271
+ // 6. Heartbeat lost
272
+ // ─────────────────────────────────────────────────────────────────────────────
273
+
274
+ describe('heartbeat lost', () => {
275
+ it('stops job and records heartbeat-lost after HEARTBEAT_KILL_POLLS consecutive errors', async () => {
276
+ // HEARTBEAT_KILL_POLLS = 15; POLL_MS = 4000ms
277
+ // First dep poll must succeed and return 'running' (to set lastStatus = 'running').
278
+ // After that, 15 consecutive failures trigger the kill.
279
+ // Total timer advances: (1 success + 15 failures) × 4000ms = 64000ms
280
+ api.callApi
281
+ .mockResolvedValueOnce(makeDep({ status: 'running' })) // POST /run
282
+ .mockResolvedValueOnce({ status: 'running' }) // first dep poll → sets lastStatus = 'running'
283
+ .mockResolvedValueOnce({ logs: [] }) // first logs poll
284
+ .mockRejectedValue(new Error('ETIMEDOUT')); // all subsequent dep/logs polls fail
285
+ api.terminateDeployment.mockResolvedValue({});
286
+
287
+ const p = runCommand(config, ['python', 'train.py'], chalk);
288
+ // Advance past 1 successful poll (4000ms) + 15 failed polls (60000ms) = 64000ms
289
+ await vi.advanceTimersByTimeAsync(66000);
290
+ await p;
291
+
292
+ expect(api.terminateDeployment).toHaveBeenCalledWith(config, 'dep-launch-001');
293
+
294
+ // teardown() writes first: billing stopped, status = 'heartbeat-lost'
295
+ expect(store.updateReceipt).toHaveBeenCalledWith('rcpt-launch-001', expect.objectContaining({
296
+ status: 'heartbeat-lost',
297
+ teardownStatus: 'terminated',
298
+ }));
299
+ // runCommand writes second: final summary with failureType
300
+ expect(store.updateReceipt).toHaveBeenCalledWith('rcpt-launch-001', expect.objectContaining({
301
+ failureType: 'infrastructure',
302
+ }));
303
+ expect(process.exitCode).toBeTruthy();
304
+ }, 30000 /* 30s real timeout — fake timers advance instantly but need sufficient budget */);
305
+ });
306
+
307
+ // ─────────────────────────────────────────────────────────────────────────────
308
+ // 7. Log streaming
309
+ // ─────────────────────────────────────────────────────────────────────────────
310
+
311
+ describe('log streaming', () => {
312
+ it('prints user-visible log lines and suppresses meta-lines', async () => {
313
+ const logLines = [];
314
+ console.log.mockImplementation((...args) => logLines.push(args.join(' ')));
315
+
316
+ api.callApi
317
+ .mockResolvedValueOnce(makeDep())
318
+ .mockResolvedValueOnce({ status: 'completed', exit_code: 0 })
319
+ .mockResolvedValueOnce({ logs: [
320
+ 'Epoch 1/3: loss=0.42', // should appear
321
+ '[dep-abc] status=running', // meta → should be filtered
322
+ '[dep-abc] gpu_util=72%', // meta → should be filtered
323
+ 'Epoch 2/3: loss=0.31', // should appear
324
+ ]});
325
+
326
+ const p = runCommand(config, ['python', 'train.py'], chalk);
327
+ await vi.advanceTimersByTimeAsync(5000);
328
+ await p;
329
+
330
+ const joined = logLines.join('\n');
331
+ expect(joined).toContain('Epoch 1/3: loss=0.42');
332
+ expect(joined).toContain('Epoch 2/3: loss=0.31');
333
+ // Meta-lines are filtered by the LOG_META_RE in run.js
334
+ expect(joined).not.toContain('[dep-abc] status=running');
335
+ });
336
+ });
337
+
338
+ // ─────────────────────────────────────────────────────────────────────────────
339
+ // 8. Routing: tier-1 unavailable → auto-expand to tier-2
340
+ // ─────────────────────────────────────────────────────────────────────────────
341
+
342
+ describe('routing fallback', () => {
343
+ it('expands to tier-2 when tier-1 returns NO_CAPACITY_MATCH', async () => {
344
+ const capacityErr = Object.assign(new Error('no capacity'), {
345
+ errorData: { code: 'NO_CAPACITY_MATCH' },
346
+ httpStatus: 503,
347
+ });
348
+ api.callApi
349
+ .mockRejectedValueOnce(capacityErr) // tier-1 attempt
350
+ .mockResolvedValueOnce(makeDep({ provider: 'modal', tier: '2' })) // tier-2 succeeds
351
+ .mockResolvedValueOnce({ status: 'completed', exit_code: 0 })
352
+ .mockResolvedValueOnce({ logs: [] });
353
+
354
+ const p = runCommand(config, ['python', 'train.py'], chalk);
355
+ await vi.advanceTimersByTimeAsync(5000);
356
+ await p;
357
+
358
+ // Should have been called twice: once for tier-1, once for tier-2
359
+ const postCalls = api.callApi.mock.calls.filter(c => c[1]?.method === 'POST');
360
+ expect(postCalls.length).toBeGreaterThanOrEqual(2);
361
+
362
+ expect(store.addReceipt).toHaveBeenCalledWith(expect.objectContaining({
363
+ providerRoute: 'modal',
364
+ tier: '2',
365
+ }));
366
+ expect(process.exitCode).toBeFalsy();
367
+ });
368
+
369
+ it('reports CapacityError when both tiers fail', async () => {
370
+ const capacityErr = Object.assign(new Error('no capacity'), {
371
+ errorData: { code: 'NO_CAPACITY_MATCH' },
372
+ httpStatus: 503,
373
+ });
374
+ api.callApi.mockRejectedValue(capacityErr);
375
+
376
+ const logs = [];
377
+ console.error.mockImplementation(msg => logs.push(msg));
378
+
379
+ await runCommand(config, ['python', 'train.py'], chalk);
380
+
381
+ expect(process.exitCode).toBe(1);
382
+ const combined = logs.join('\n');
383
+ expect(combined).toMatch(/No suitable GPU capacity|capacity/i);
384
+ });
385
+
386
+ it('does not fall back to tier-2 when --no-fallback is set', async () => {
387
+ const capacityErr = Object.assign(new Error('no capacity'), {
388
+ errorData: { code: 'NO_CAPACITY_MATCH' },
389
+ httpStatus: 503,
390
+ });
391
+ api.callApi.mockRejectedValueOnce(capacityErr);
392
+
393
+ await runCommand(config, ['python', 'train.py', '--no-fallback'], chalk);
394
+
395
+ // Only one POST /run call was made (no tier-2 expansion)
396
+ const postCalls = api.callApi.mock.calls.filter(c => c[1]?.method === 'POST');
397
+ expect(postCalls.length).toBe(1);
398
+ expect(process.exitCode).toBe(1);
399
+ });
400
+
401
+ it('PROVISIONING_FAILED error is surfaced clearly without showing internal debug info by default', async () => {
402
+ const provErr = Object.assign(new Error('provisioning failed'), {
403
+ errorData: { code: 'PROVISIONING_FAILED', debug_error: 'CUDA driver mismatch' },
404
+ httpStatus: 503,
405
+ });
406
+ api.callApi.mockRejectedValue(provErr);
407
+
408
+ const errLines = [];
409
+ console.error.mockImplementation(msg => errLines.push(msg));
410
+
411
+ await runCommand(config, ['python', 'train.py'], chalk);
412
+
413
+ expect(process.exitCode).toBe(1);
414
+ const combined = errLines.join('\n');
415
+ expect(combined).not.toContain('CUDA driver mismatch'); // debug info hidden by default
416
+ expect(combined).toMatch(/try again|failed to start/i);
417
+ });
418
+ });
419
+
420
+ // ─────────────────────────────────────────────────────────────────────────────
421
+ // 9. Payment required
422
+ // ─────────────────────────────────────────────────────────────────────────────
423
+
424
+ describe('payment required', () => {
425
+ it('shows billing message and rerun hint, exits 1', async () => {
426
+ const payErr = Object.assign(new Error('Payment required'), {
427
+ isPaymentRequired: true,
428
+ httpStatus: 402,
429
+ });
430
+ api.callApi.mockRejectedValueOnce(payErr);
431
+
432
+ const errLines = [];
433
+ console.error.mockImplementation(msg => errLines.push(msg));
434
+
435
+ await runCommand(config, ['python', 'train.py', '--gpu', 'A100'], chalk);
436
+
437
+ expect(process.exitCode).toBe(1);
438
+ const combined = errLines.join('\n');
439
+ expect(combined).toContain('Payment required');
440
+ });
441
+ });
442
+
443
+ // ─────────────────────────────────────────────────────────────────────────────
444
+ // 10. Billing stops — terminateDeployment called on every terminal path
445
+ // ─────────────────────────────────────────────────────────────────────────────
446
+
447
+ describe('billing lifecycle', () => {
448
+ it('terminateDeployment is called after successful completion', async () => {
449
+ setupSuccessfulRun();
450
+ const p = runCommand(config, ['python', 'train.py'], chalk);
451
+ await vi.advanceTimersByTimeAsync(5000);
452
+ await p;
453
+ expect(api.terminateDeployment).toHaveBeenCalledWith(config, 'dep-launch-001');
454
+ });
455
+
456
+ it('receipt records runtime and finalCost on completion', async () => {
457
+ setupSuccessfulRun({ cost_per_hour: 1.80 });
458
+ const p = runCommand(config, ['python', 'train.py'], chalk);
459
+ await vi.advanceTimersByTimeAsync(5000);
460
+ await p;
461
+
462
+ const updateCall = store.updateReceipt.mock.calls.find(
463
+ c => c[1]?.runtimeSeconds !== undefined,
464
+ );
465
+ expect(updateCall).toBeTruthy();
466
+ expect(typeof updateCall[1].runtimeSeconds).toBe('number');
467
+ expect(typeof updateCall[1].finalCost).toBe('number');
468
+ });
469
+ });
470
+
471
+ // ─────────────────────────────────────────────────────────────────────────────
472
+ // 11. Input validation before provisioning
473
+ // ─────────────────────────────────────────────────────────────────────────────
474
+
475
+ describe('input validation', () => {
476
+ it('rejects --count 0 before any API call', async () => {
477
+ await runCommand(config, ['python', 'train.py', '--count', '0'], chalk);
478
+ expect(api.callApi).not.toHaveBeenCalled();
479
+ expect(process.exitCode).toBe(1);
480
+ });
481
+
482
+ it('rejects --max-cost 0 before any API call', async () => {
483
+ await runCommand(config, ['python', 'train.py', '--max-cost', '0'], chalk);
484
+ expect(api.callApi).not.toHaveBeenCalled();
485
+ expect(process.exitCode).toBe(1);
486
+ });
487
+
488
+ it('rejects unknown --region before any API call', async () => {
489
+ await runCommand(config, ['python', 'train.py', '--region', 'MARS'], chalk);
490
+ expect(api.callApi).not.toHaveBeenCalled();
491
+ expect(process.exitCode).toBe(1);
492
+ });
493
+
494
+ it('rejects empty command with no --image', async () => {
495
+ await runCommand(config, [], chalk);
496
+ expect(api.callApi).not.toHaveBeenCalled();
497
+ });
498
+ });