badgr-cli 1.0.35 → 1.0.37

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,128 @@
1
+ /**
2
+ * badgr down — receipt display and runtime formatting tests
3
+ */
4
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
5
+ import { downCommand } from '../src/commands/down.js';
6
+
7
+ vi.mock('../src/api.js', () => ({
8
+ terminateDeployment: vi.fn(),
9
+ listDeployments: vi.fn(),
10
+ }));
11
+
12
+ vi.mock('../src/store.js', () => ({
13
+ findDeployment: vi.fn(() => null),
14
+ removeDeployment: vi.fn(),
15
+ addReceipt: vi.fn(),
16
+ generateReceiptId: vi.fn(() => 'rcpt-test-001'),
17
+ }));
18
+
19
+ vi.mock('../src/config.js', () => ({
20
+ requireApiKey: vi.fn(),
21
+ }));
22
+
23
+ import * as api from '../src/api.js';
24
+ import * as store from '../src/store.js';
25
+
26
+ const config = { apiKey: 'sk-test', baseUrl: 'https://api.test/v1' };
27
+
28
+ const chalk = {
29
+ bold: s => s,
30
+ dim: s => s,
31
+ red: s => s,
32
+ yellow: s => s,
33
+ green: s => s,
34
+ cyan: s => s,
35
+ };
36
+
37
+ function makeStoppedDep(runtimeSeconds, costPerHour = 1.00) {
38
+ const now = Date.now() / 1000;
39
+ return {
40
+ deployment_id: 'dep-test-001',
41
+ gpu_type: 'L40S',
42
+ cost_per_hour: costPerHour,
43
+ stopped_at: now,
44
+ started_at: now - runtimeSeconds,
45
+ };
46
+ }
47
+
48
+ beforeEach(() => {
49
+ vi.spyOn(console, 'log').mockImplementation(() => {});
50
+ vi.spyOn(console, 'error').mockImplementation(() => {});
51
+ vi.spyOn(process.stdout, 'write').mockImplementation(() => true);
52
+ vi.resetAllMocks();
53
+ store.findDeployment.mockReturnValue(null);
54
+ store.generateReceiptId.mockReturnValue('rcpt-test-001');
55
+ });
56
+
57
+ afterEach(() => {
58
+ vi.restoreAllMocks();
59
+ });
60
+
61
+ describe('runtime display formatting', () => {
62
+ it('shows minutes only for short runs (< 1h)', async () => {
63
+ api.terminateDeployment.mockResolvedValue(makeStoppedDep(27 * 60, 0.34));
64
+ const lines = [];
65
+ console.log.mockImplementation((...args) => lines.push(args.join(' ')));
66
+
67
+ await downCommand(config, ['dep-test-001'], chalk);
68
+
69
+ const runtimeLine = lines.find(l => l.includes('Runtime:'));
70
+ expect(runtimeLine).toMatch(/27m/);
71
+ expect(runtimeLine).not.toMatch(/\dh/);
72
+ });
73
+
74
+ it('shows hours and minutes for runs >= 1h', async () => {
75
+ api.terminateDeployment.mockResolvedValue(makeStoppedDep(4091 * 60, 1.00));
76
+ const lines = [];
77
+ console.log.mockImplementation((...args) => lines.push(args.join(' ')));
78
+
79
+ await downCommand(config, ['dep-test-001'], chalk);
80
+
81
+ const runtimeLine = lines.find(l => l.includes('Runtime:'));
82
+ expect(runtimeLine).toMatch(/2d 20h 11m/);
83
+ expect(runtimeLine).not.toMatch(/4091m/);
84
+ });
85
+
86
+ it('shows days, hours, minutes for multi-day runs', async () => {
87
+ api.terminateDeployment.mockResolvedValue(makeStoppedDep(5722 * 60, 0.46));
88
+ const lines = [];
89
+ console.log.mockImplementation((...args) => lines.push(args.join(' ')));
90
+
91
+ await downCommand(config, ['dep-test-001'], chalk);
92
+
93
+ const runtimeLine = lines.find(l => l.includes('Runtime:'));
94
+ expect(runtimeLine).toMatch(/3d 23h 22m/);
95
+ expect(runtimeLine).not.toMatch(/5722m/);
96
+ });
97
+ });
98
+
99
+ describe('receipt is recorded', () => {
100
+ it('adds a receipt with correct fields on stop', async () => {
101
+ api.terminateDeployment.mockResolvedValue(makeStoppedDep(27 * 60, 0.34));
102
+
103
+ await downCommand(config, ['dep-test-001'], chalk);
104
+
105
+ expect(store.addReceipt).toHaveBeenCalledWith(expect.objectContaining({
106
+ receiptId: 'rcpt-test-001',
107
+ deploymentId: 'dep-test-001',
108
+ gpu: 'L40S',
109
+ status: 'terminated',
110
+ }));
111
+ const call = store.addReceipt.mock.calls[0][0];
112
+ expect(call.runtimeSeconds).toBeCloseTo(27 * 60, -1);
113
+ expect(call.finalCost).toBeGreaterThan(0);
114
+ });
115
+ });
116
+
117
+ describe('error handling', () => {
118
+ it('prints error and returns when terminateDeployment throws', async () => {
119
+ api.terminateDeployment.mockRejectedValue(new Error('network error'));
120
+ const errLines = [];
121
+ console.error.mockImplementation(msg => errLines.push(msg));
122
+
123
+ await downCommand(config, ['dep-test-001'], chalk);
124
+
125
+ expect(errLines.join('\n')).toMatch(/network error|Could not stop/i);
126
+ expect(store.addReceipt).not.toHaveBeenCalled();
127
+ });
128
+ });
@@ -324,3 +324,54 @@ describe('compat_failure error message', () => {
324
324
  expect(thrown.message).toMatch(/GPU type|image/i);
325
325
  });
326
326
  });
327
+
328
+ // ─────────────────────────────────────────────────────────────────────────────
329
+ // 4. Stale-capacity retry — PROVISIONING_FAILED triggers one immediate retry
330
+ // ─────────────────────────────────────────────────────────────────────────────
331
+
332
+ describe('stale-capacity retry on PROVISIONING_FAILED', () => {
333
+ function makeProvisioningFailedErr() {
334
+ const err = new Error('PROVISIONING_FAILED');
335
+ err.errorData = { code: 'PROVISIONING_FAILED', failure_category: 'infrastructure' };
336
+ return err;
337
+ }
338
+
339
+ it('retries the API call once before escalating on PROVISIONING_FAILED (tier=2 → no tier-2 expansion)', async () => {
340
+ // tier=2 means no tier-2 fallback expansion, so only the stale-capacity retry fires.
341
+ api.callApi.mockRejectedValue(makeProvisioningFailedErr());
342
+
343
+ try {
344
+ await callWithFallback(
345
+ '/run',
346
+ { apiKey: 'sk-test', baseUrl: 'https://api.test/v1' },
347
+ () => ({}),
348
+ '2',
349
+ chalk,
350
+ { thing: 'job', cmd: 'badgr run' },
351
+ );
352
+ } catch (_) {}
353
+
354
+ // First attempt + one stale-capacity retry = 2 calls minimum
355
+ expect(api.callApi.mock.calls.length).toBeGreaterThanOrEqual(2);
356
+ });
357
+
358
+ it('does NOT double-retry on non-PROVISIONING_FAILED errors', async () => {
359
+ const err = new Error('NO_CAPACITY_MATCH');
360
+ err.errorData = { code: 'NO_CAPACITY_MATCH' };
361
+ api.callApi.mockRejectedValue(err);
362
+
363
+ try {
364
+ await callWithFallback(
365
+ '/run',
366
+ { apiKey: 'sk-test', baseUrl: 'https://api.test/v1' },
367
+ () => ({}),
368
+ '2',
369
+ chalk,
370
+ { thing: 'job', cmd: 'badgr run' },
371
+ );
372
+ } catch (_) {}
373
+
374
+ // tier=2 → no tier-2 expansion; no stale-capacity retry for NO_CAPACITY_MATCH
375
+ expect(api.callApi.mock.calls.length).toBe(1);
376
+ });
377
+ });
@@ -22,6 +22,7 @@ import { serveCommand } from '../src/commands/serve.js';
22
22
  vi.mock('../src/api.js', () => ({
23
23
  callApi: vi.fn(),
24
24
  terminateDeployment: vi.fn().mockResolvedValue({}),
25
+ listDeployments: vi.fn().mockResolvedValue({ deployments: [], count: 0 }),
25
26
  }));
26
27
 
27
28
  vi.mock('../src/store.js', () => ({
@@ -107,7 +108,7 @@ describe('successful serve', () => {
107
108
  text: async () => '',
108
109
  });
109
110
 
110
- const p = serveCommand(config, ['meta-llama/Llama-3.1-8B-Instruct', '--gpu', 'L40S'], chalk);
111
+ const p = serveCommand(config, ['meta-llama/Llama-3.1-8B-Instruct', '--gpu', 'L40S', '--max-cost', '5'], chalk);
111
112
  await vi.advanceTimersByTimeAsync(5000);
112
113
  await p;
113
114
 
@@ -131,7 +132,7 @@ describe('successful serve', () => {
131
132
 
132
133
  global.fetch = vi.fn().mockResolvedValue({ ok: true, status: 200, json: async () => ({}) });
133
134
 
134
- const p = serveCommand(config, ['mistralai/Mistral-7B-v0.1', '--gpu', 'A100'], chalk);
135
+ const p = serveCommand(config, ['mistralai/Mistral-7B-v0.1', '--gpu', 'A100', '--max-cost', '5'], chalk);
135
136
  await vi.advanceTimersByTimeAsync(5000);
136
137
  await p;
137
138
 
@@ -156,7 +157,7 @@ describe('billing ends when deployment stops', () => {
156
157
 
157
158
  global.fetch = vi.fn().mockResolvedValue({ ok: true, status: 200, json: async () => ({}) });
158
159
 
159
- const p = serveCommand(config, ['meta-llama/Llama-3.1-8B-Instruct'], chalk);
160
+ const p = serveCommand(config, ['meta-llama/Llama-3.1-8B-Instruct', '--max-cost', '5'], chalk);
160
161
  await vi.advanceTimersByTimeAsync(5000);
161
162
  await p;
162
163
 
@@ -182,7 +183,7 @@ describe('deployment fails during startup', () => {
182
183
  .mockResolvedValueOnce(makeServeDep()) // POST /serve
183
184
  .mockResolvedValueOnce({ status: 'failed', error: 'OOM: not enough VRAM' }); // pre-health check
184
185
 
185
- const p = serveCommand(config, ['meta-llama/Llama-3.1-8B-Instruct'], chalk);
186
+ const p = serveCommand(config, ['meta-llama/Llama-3.1-8B-Instruct', '--max-cost', '5'], chalk);
186
187
  await vi.advanceTimersByTimeAsync(2000);
187
188
  await p;
188
189
 
@@ -201,7 +202,7 @@ describe('deployment fails during startup', () => {
201
202
  // Health check endpoint doesn't respond (dep crashed)
202
203
  global.fetch = vi.fn().mockRejectedValue(new Error('Connection refused'));
203
204
 
204
- const p = serveCommand(config, ['meta-llama/Llama-3.1-8B-Instruct'], chalk);
205
+ const p = serveCommand(config, ['meta-llama/Llama-3.1-8B-Instruct', '--max-cost', '5'], chalk);
205
206
  // need to advance past the 8000ms sleep in waitForEndpoint loop
206
207
  await vi.advanceTimersByTimeAsync(10000);
207
208
  await p;
@@ -227,7 +228,7 @@ describe('health check timeout', () => {
227
228
  // Health endpoint never responds
228
229
  global.fetch = vi.fn().mockRejectedValue(new Error('ETIMEDOUT'));
229
230
 
230
- const p = serveCommand(config, ['meta-llama/Llama-3.1-8B-Instruct'], chalk);
231
+ const p = serveCommand(config, ['meta-llama/Llama-3.1-8B-Instruct', '--max-cost', '5'], chalk);
231
232
  // waitForEndpoint timeout is 5 * 60 * 1000 = 300000ms
232
233
  await vi.advanceTimersByTimeAsync(310000);
233
234
  await p;
@@ -256,7 +257,7 @@ describe('invalid model ID or no capacity', () => {
256
257
  const errLines = [];
257
258
  console.error.mockImplementation(msg => errLines.push(msg));
258
259
 
259
- await serveCommand(config, ['invalid/nonexistent-model-xyz'], chalk);
260
+ await serveCommand(config, ['invalid/nonexistent-model-xyz', '--max-cost', '5'], chalk);
260
261
 
261
262
  expect(process.exitCode).toBe(1);
262
263
  const combined = errLines.join('\n');
@@ -276,7 +277,7 @@ describe('invalid model ID or no capacity', () => {
276
277
  const errLines = [];
277
278
  console.error.mockImplementation(msg => errLines.push(msg));
278
279
 
279
- await serveCommand(config, ['my-org/private-model'], chalk);
280
+ await serveCommand(config, ['my-org/private-model', '--max-cost', '5'], chalk);
280
281
 
281
282
  expect(process.exitCode).toBe(1);
282
283
  // Debug info hidden by default (BADGR_DEBUG not set)
@@ -293,7 +294,7 @@ describe('invalid model ID or no capacity', () => {
293
294
  });
294
295
  api.callApi.mockRejectedValue(provErr);
295
296
 
296
- await serveCommand(config, ['some/model', '--task', 'unknown_task'], chalk);
297
+ await serveCommand(config, ['some/model', '--task', 'unknown_task', '--max-cost', '5'], chalk);
297
298
 
298
299
  expect(process.exitCode).toBe(1);
299
300
  expect(store.addDeployment).not.toHaveBeenCalled();
@@ -309,7 +310,7 @@ describe('--no-wait flag', () => {
309
310
  api.callApi.mockResolvedValueOnce(makeServeDep());
310
311
  global.fetch = vi.fn(); // should never be called
311
312
 
312
- const p = serveCommand(config, ['meta-llama/Llama-3.1-8B-Instruct', '--no-wait'], chalk);
313
+ const p = serveCommand(config, ['meta-llama/Llama-3.1-8B-Instruct', '--no-wait', '--max-cost', '5'], chalk);
313
314
  await vi.advanceTimersByTimeAsync(100);
314
315
  await p;
315
316
 
@@ -333,7 +334,7 @@ describe('custom image health check', () => {
333
334
 
334
335
  const p = serveCommand(
335
336
  config,
336
- ['--image', 'ghcr.io/my-org/diffusers-api:latest', '--gpu', 'L40S'],
337
+ ['--image', 'ghcr.io/my-org/diffusers-api:latest', '--gpu', 'L40S', '--max-cost', '5'],
337
338
  chalk,
338
339
  );
339
340
  await vi.advanceTimersByTimeAsync(100);
@@ -356,7 +357,7 @@ describe('custom image health check', () => {
356
357
 
357
358
  const p = serveCommand(
358
359
  config,
359
- ['--image', 'ghcr.io/my-org/comfyui-custom:latest', '--gpu', 'A100'],
360
+ ['--image', 'ghcr.io/my-org/comfyui-custom:latest', '--gpu', 'A100', '--max-cost', '5'],
360
361
  chalk,
361
362
  );
362
363
  await vi.advanceTimersByTimeAsync(5000);
@@ -380,7 +381,7 @@ describe('custom image health check', () => {
380
381
 
381
382
  const p = serveCommand(
382
383
  config,
383
- ['--image', 'ghcr.io/my-org/stable-diffusion:latest', '--health-path', '/health'],
384
+ ['--image', 'ghcr.io/my-org/stable-diffusion:latest', '--health-path', '/health', '--max-cost', '5'],
384
385
  chalk,
385
386
  );
386
387
  await vi.advanceTimersByTimeAsync(5000);
@@ -404,7 +405,7 @@ describe('custom image health check', () => {
404
405
 
405
406
  const p = serveCommand(
406
407
  config,
407
- ['meta-llama/Llama-3.1-8B-Instruct', '--health-path', '/v1/health'],
408
+ ['meta-llama/Llama-3.1-8B-Instruct', '--health-path', '/v1/health', '--max-cost', '5'],
408
409
  chalk,
409
410
  );
410
411
  await vi.advanceTimersByTimeAsync(5000);
@@ -433,7 +434,7 @@ describe('tier-2 fallback for serve', () => {
433
434
 
434
435
  global.fetch = vi.fn().mockResolvedValue({ ok: true, status: 200, json: async () => ({}) });
435
436
 
436
- const p = serveCommand(config, ['meta-llama/Llama-3.1-8B-Instruct'], chalk);
437
+ const p = serveCommand(config, ['meta-llama/Llama-3.1-8B-Instruct', '--max-cost', '5'], chalk);
437
438
  await vi.advanceTimersByTimeAsync(5000);
438
439
  await p;
439
440
 
@@ -450,7 +451,7 @@ describe('tier-2 fallback for serve', () => {
450
451
  });
451
452
  api.callApi.mockRejectedValueOnce(capacityErr);
452
453
 
453
- await serveCommand(config, ['meta-llama/Llama-3.1-8B-Instruct', '--no-fallback'], chalk);
454
+ await serveCommand(config, ['meta-llama/Llama-3.1-8B-Instruct', '--no-fallback', '--max-cost', '5'], chalk);
454
455
 
455
456
  const postCalls = api.callApi.mock.calls.filter(c => c[1]?.method === 'POST');
456
457
  expect(postCalls.length).toBe(1);
@@ -466,7 +467,7 @@ describe('missing endpoint URL guard', () => {
466
467
  it('exits with error when backend returns no endpoint_url', async () => {
467
468
  api.callApi.mockResolvedValueOnce(makeServeDep({ endpoint_url: null, openai_base_url: null }));
468
469
 
469
- await serveCommand(config, ['meta-llama/Llama-3.1-8B-Instruct'], chalk);
470
+ await serveCommand(config, ['meta-llama/Llama-3.1-8B-Instruct', '--max-cost', '5'], chalk);
470
471
 
471
472
  expect(process.exitCode).toBe(1);
472
473
  expect(store.updateReceipt).toHaveBeenCalledWith('rcpt-serve-001', expect.objectContaining({