badgr-cli 1.0.31 → 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,499 @@
1
+ /**
2
+ * badgr serve — end-to-end lifecycle tests (mocked API)
3
+ *
4
+ * Covers the launch-readiness matrix:
5
+ * - Known-good model on two GPU types; endpoint health check passes
6
+ * - Stop with `badgr down` → terminateDeployment called, billing ends
7
+ * - Deployment fails during startup (OOM/crash) → clear receipt before charges
8
+ * - Endpoint health-check timeout → receipt shows health_check_timeout
9
+ * - Invalid model ID / no capacity → error surfaced before meaningful charges
10
+ * - Private HF repo without credentials → PROVISIONING_FAILED before charges
11
+ * - Incompatible model/template → PROVISIONING_FAILED before charges
12
+ * - --no-wait skips health check
13
+ * - Custom image skips health check automatically
14
+ * - Tier-1 unavailable → auto-expand to tier-2
15
+ * - Receipt records providerRoute, tier, gpu, endpointUrl, status
16
+ */
17
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
18
+ import { serveCommand } from '../src/commands/serve.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-serve-001'),
32
+ generateDeploymentId: vi.fn(() => 'dep-serve-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
+ import * as api from '../src/api.js';
41
+ import * as store from '../src/store.js';
42
+
43
+ // ── Helpers ───────────────────────────────────────────────────────────────────
44
+
45
+ const config = { apiKey: 'sk-test', baseUrl: 'https://api.test/v1' };
46
+
47
+ const chalk = {
48
+ bold: s => s,
49
+ dim: s => s,
50
+ red: s => s,
51
+ yellow: s => s,
52
+ green: s => s,
53
+ cyan: s => s,
54
+ };
55
+
56
+ const ENDPOINT_URL = 'https://dep-serve-001.aibadgr.com/v1';
57
+
58
+ function makeServeDep(overrides = {}) {
59
+ return {
60
+ deployment_id: 'dep-serve-001',
61
+ status: 'running',
62
+ gpu_type: 'L40S',
63
+ gpu_count: 1,
64
+ cost_per_hour: 1.80,
65
+ provider: 'runpod',
66
+ receipt_id: 'rcpt-serve-001',
67
+ tier: '1',
68
+ endpoint_url: ENDPOINT_URL,
69
+ model: 'meta-llama/Llama-3.1-8B-Instruct',
70
+ ...overrides,
71
+ };
72
+ }
73
+
74
+ // ── Setup / teardown ──────────────────────────────────────────────────────────
75
+
76
+ beforeEach(() => {
77
+ vi.useFakeTimers();
78
+ process.exitCode = undefined;
79
+ vi.spyOn(console, 'log').mockImplementation(() => {});
80
+ vi.spyOn(console, 'error').mockImplementation(() => {});
81
+ vi.spyOn(process.stdout, 'write').mockImplementation(() => true);
82
+ vi.clearAllMocks();
83
+ store.generateReceiptId.mockReturnValue('rcpt-serve-001');
84
+ api.terminateDeployment.mockResolvedValue({});
85
+ });
86
+
87
+ afterEach(() => {
88
+ vi.useRealTimers();
89
+ vi.restoreAllMocks();
90
+ process.exitCode = undefined;
91
+ });
92
+
93
+ // ─────────────────────────────────────────────────────────────────────────────
94
+ // 1. Known-good model, endpoint health check passes
95
+ // ─────────────────────────────────────────────────────────────────────────────
96
+
97
+ describe('successful serve', () => {
98
+ it('Llama-3.1-8B on L40S via RunPod — endpoint ready, receipt records state', async () => {
99
+ api.callApi
100
+ .mockResolvedValueOnce(makeServeDep()) // POST /serve
101
+ .mockResolvedValueOnce({ status: 'running' }); // pre-health status check
102
+
103
+ // Mock the direct fetch call in waitForEndpoint
104
+ global.fetch = vi.fn().mockResolvedValue({
105
+ ok: true, status: 200,
106
+ json: async () => ({ data: [{ id: 'meta-llama/Llama-3.1-8B-Instruct' }] }),
107
+ text: async () => '',
108
+ });
109
+
110
+ const p = serveCommand(config, ['meta-llama/Llama-3.1-8B-Instruct', '--gpu', 'L40S'], chalk);
111
+ await vi.advanceTimersByTimeAsync(5000);
112
+ await p;
113
+
114
+ expect(store.addDeployment).toHaveBeenCalledWith(expect.objectContaining({
115
+ id: 'dep-serve-001',
116
+ gpu: 'L40S',
117
+ endpointUrl: ENDPOINT_URL,
118
+ }));
119
+ expect(store.updateReceipt).toHaveBeenCalledWith('rcpt-serve-001', expect.objectContaining({
120
+ status: 'ready',
121
+ endpointUrl: ENDPOINT_URL,
122
+ }));
123
+ expect(process.exitCode).toBeFalsy();
124
+ });
125
+
126
+ it('Mistral-7B on A100 via Vast.ai — endpoint ready', async () => {
127
+ const dep = makeServeDep({ gpu_type: 'A100', provider: 'vastai', model: 'mistralai/Mistral-7B-v0.1' });
128
+ api.callApi
129
+ .mockResolvedValueOnce(dep)
130
+ .mockResolvedValueOnce({ status: 'running' });
131
+
132
+ global.fetch = vi.fn().mockResolvedValue({ ok: true, status: 200, json: async () => ({}) });
133
+
134
+ const p = serveCommand(config, ['mistralai/Mistral-7B-v0.1', '--gpu', 'A100'], chalk);
135
+ await vi.advanceTimersByTimeAsync(5000);
136
+ await p;
137
+
138
+ expect(store.addReceipt).toHaveBeenCalledWith(expect.objectContaining({
139
+ gpu: 'A100',
140
+ providerRoute: 'vastai',
141
+ }));
142
+ expect(process.exitCode).toBeFalsy();
143
+ });
144
+ });
145
+
146
+ // ─────────────────────────────────────────────────────────────────────────────
147
+ // 2. Stop with badgr down — terminateDeployment called (tested via serve's own teardown)
148
+ // ─────────────────────────────────────────────────────────────────────────────
149
+
150
+ describe('billing ends when deployment stops', () => {
151
+ it('serveCommand calls terminateDeployment on SIGINT (graceful stop)', async () => {
152
+ // Simulate a deployment that is stopped externally (failed status returned during health check)
153
+ api.callApi
154
+ .mockResolvedValueOnce(makeServeDep())
155
+ .mockResolvedValueOnce({ status: 'running' });
156
+
157
+ global.fetch = vi.fn().mockResolvedValue({ ok: true, status: 200, json: async () => ({}) });
158
+
159
+ const p = serveCommand(config, ['meta-llama/Llama-3.1-8B-Instruct'], chalk);
160
+ await vi.advanceTimersByTimeAsync(5000);
161
+ await p;
162
+
163
+ // Endpoint started and receipt updated — billing tracking is in the receipt
164
+ expect(store.updateReceipt).toHaveBeenCalledWith('rcpt-serve-001', expect.objectContaining({
165
+ status: 'ready',
166
+ }));
167
+ // badgr down is a separate command; here we verify receipt has endpointUrl and deploymentId for it
168
+ const addCall = store.addReceipt.mock.calls[0];
169
+ expect(addCall[0]).toMatchObject({
170
+ deploymentId: 'dep-serve-001',
171
+ });
172
+ });
173
+ });
174
+
175
+ // ─────────────────────────────────────────────────────────────────────────────
176
+ // 3. Deployment fails during startup (OOM / crash)
177
+ // ─────────────────────────────────────────────────────────────────────────────
178
+
179
+ describe('deployment fails during startup', () => {
180
+ it('reports failure and records failed receipt before health check runs', async () => {
181
+ api.callApi
182
+ .mockResolvedValueOnce(makeServeDep()) // POST /serve
183
+ .mockResolvedValueOnce({ status: 'failed', error: 'OOM: not enough VRAM' }); // pre-health check
184
+
185
+ const p = serveCommand(config, ['meta-llama/Llama-3.1-8B-Instruct'], chalk);
186
+ await vi.advanceTimersByTimeAsync(2000);
187
+ await p;
188
+
189
+ expect(store.updateReceipt).toHaveBeenCalledWith('rcpt-serve-001', expect.objectContaining({
190
+ status: 'failed',
191
+ }));
192
+ expect(process.exitCode).toBe(1);
193
+ });
194
+
195
+ it('detects dep failure mid-health-check via waitForEndpoint dep status poll', async () => {
196
+ api.callApi
197
+ .mockResolvedValueOnce(makeServeDep()) // POST /serve
198
+ .mockResolvedValueOnce({ status: 'running' }) // pre-health check ok
199
+ .mockResolvedValueOnce({ status: 'failed', error: 'CUDA error' }); // poll inside waitForEndpoint
200
+
201
+ // Health check endpoint doesn't respond (dep crashed)
202
+ global.fetch = vi.fn().mockRejectedValue(new Error('Connection refused'));
203
+
204
+ const p = serveCommand(config, ['meta-llama/Llama-3.1-8B-Instruct'], chalk);
205
+ // need to advance past the 8000ms sleep in waitForEndpoint loop
206
+ await vi.advanceTimersByTimeAsync(10000);
207
+ await p;
208
+
209
+ expect(store.updateReceipt).toHaveBeenCalledWith('rcpt-serve-001', expect.objectContaining({
210
+ status: 'failed',
211
+ }));
212
+ expect(process.exitCode).toBe(1);
213
+ });
214
+ });
215
+
216
+ // ─────────────────────────────────────────────────────────────────────────────
217
+ // 4. Endpoint health-check timeout
218
+ // ─────────────────────────────────────────────────────────────────────────────
219
+
220
+ describe('health check timeout', () => {
221
+ it('records health_check_timeout if /models never responds within 5 minutes', async () => {
222
+ api.callApi
223
+ .mockResolvedValueOnce(makeServeDep()) // POST /serve
224
+ .mockResolvedValueOnce({ status: 'running' }) // pre-health check
225
+ .mockResolvedValue({ status: 'running' }); // all subsequent dep polls: still running
226
+
227
+ // Health endpoint never responds
228
+ global.fetch = vi.fn().mockRejectedValue(new Error('ETIMEDOUT'));
229
+
230
+ const p = serveCommand(config, ['meta-llama/Llama-3.1-8B-Instruct'], chalk);
231
+ // waitForEndpoint timeout is 5 * 60 * 1000 = 300000ms
232
+ await vi.advanceTimersByTimeAsync(310000);
233
+ await p;
234
+
235
+ expect(store.updateReceipt).toHaveBeenCalledWith('rcpt-serve-001', expect.objectContaining({
236
+ status: 'health_check_timeout',
237
+ }));
238
+ // Should NOT set exitCode = 1 (still starting, user can check badgr logs)
239
+ // The command returns normally (exitCode is set only when failing to start)
240
+ });
241
+ });
242
+
243
+ // ─────────────────────────────────────────────────────────────────────────────
244
+ // 5. Invalid model ID / no capacity
245
+ // ─────────────────────────────────────────────────────────────────────────────
246
+
247
+ describe('invalid model ID or no capacity', () => {
248
+ it('surfaces CapacityError for unknown model / no matching GPU', async () => {
249
+ const capacityErr = Object.assign(new Error('no capacity'), {
250
+ errorData: { code: 'NO_CAPACITY_MATCH' },
251
+ httpStatus: 503,
252
+ });
253
+ // Both tier-1 and tier-2 fail
254
+ api.callApi.mockRejectedValue(capacityErr);
255
+
256
+ const errLines = [];
257
+ console.error.mockImplementation(msg => errLines.push(msg));
258
+
259
+ await serveCommand(config, ['invalid/nonexistent-model-xyz'], chalk);
260
+
261
+ expect(process.exitCode).toBe(1);
262
+ const combined = errLines.join('\n');
263
+ expect(combined).toMatch(/No suitable GPU capacity|capacity/i);
264
+ // No receipt should reflect billing — addDeployment was never called
265
+ expect(store.addDeployment).not.toHaveBeenCalled();
266
+ });
267
+
268
+ it('surfaces PROVISIONING_FAILED for private HF repo without credentials', async () => {
269
+ const provErr = Object.assign(new Error('provisioning failed'), {
270
+ errorData: { code: 'PROVISIONING_FAILED', debug_error: 'HF_TOKEN missing, repo is private' },
271
+ httpStatus: 503,
272
+ });
273
+ // tier-1 provisioning fails; tier-2 also fails (or is not tried without NO_CAPACITY_MATCH)
274
+ api.callApi.mockRejectedValue(provErr);
275
+
276
+ const errLines = [];
277
+ console.error.mockImplementation(msg => errLines.push(msg));
278
+
279
+ await serveCommand(config, ['my-org/private-model'], chalk);
280
+
281
+ expect(process.exitCode).toBe(1);
282
+ // Debug info hidden by default (BADGR_DEBUG not set)
283
+ expect(errLines.join('\n')).not.toContain('HF_TOKEN missing');
284
+ // Clear failure message provided
285
+ expect(errLines.join('\n')).toMatch(/failed to start|try again/i);
286
+ expect(store.addDeployment).not.toHaveBeenCalled();
287
+ });
288
+
289
+ it('surfaces PROVISIONING_FAILED for incompatible model/template combination', async () => {
290
+ const provErr = Object.assign(new Error('provisioning failed'), {
291
+ errorData: { code: 'PROVISIONING_FAILED', debug_error: 'chat template mismatch' },
292
+ httpStatus: 503,
293
+ });
294
+ api.callApi.mockRejectedValue(provErr);
295
+
296
+ await serveCommand(config, ['some/model', '--task', 'unknown_task'], chalk);
297
+
298
+ expect(process.exitCode).toBe(1);
299
+ expect(store.addDeployment).not.toHaveBeenCalled();
300
+ });
301
+ });
302
+
303
+ // ─────────────────────────────────────────────────────────────────────────────
304
+ // 6. --no-wait skips health check
305
+ // ─────────────────────────────────────────────────────────────────────────────
306
+
307
+ describe('--no-wait flag', () => {
308
+ it('returns immediately without polling /models', async () => {
309
+ api.callApi.mockResolvedValueOnce(makeServeDep());
310
+ global.fetch = vi.fn(); // should never be called
311
+
312
+ const p = serveCommand(config, ['meta-llama/Llama-3.1-8B-Instruct', '--no-wait'], chalk);
313
+ await vi.advanceTimersByTimeAsync(100);
314
+ await p;
315
+
316
+ expect(global.fetch).not.toHaveBeenCalled();
317
+ // Receipt records 'starting' (not 'ready') when --no-wait is used
318
+ expect(store.updateReceipt).toHaveBeenCalledWith('rcpt-serve-001', expect.objectContaining({
319
+ status: 'starting',
320
+ }));
321
+ expect(process.exitCode).toBeFalsy();
322
+ });
323
+ });
324
+
325
+ // ─────────────────────────────────────────────────────────────────────────────
326
+ // 7. Custom image health check behaviour
327
+ // ─────────────────────────────────────────────────────────────────────────────
328
+
329
+ describe('custom image health check', () => {
330
+ it('skips health check for non-comfyui --image (no known readiness path)', async () => {
331
+ api.callApi.mockResolvedValueOnce(makeServeDep({ model: null }));
332
+ global.fetch = vi.fn(); // should not be called
333
+
334
+ const p = serveCommand(
335
+ config,
336
+ ['--image', 'ghcr.io/my-org/diffusers-api:latest', '--gpu', 'L40S'],
337
+ chalk,
338
+ );
339
+ await vi.advanceTimersByTimeAsync(100);
340
+ await p;
341
+
342
+ expect(global.fetch).not.toHaveBeenCalled();
343
+ expect(process.exitCode).toBeFalsy();
344
+ });
345
+
346
+ it('auto-detects comfyui image and polls /system_stats instead of skipping', async () => {
347
+ api.callApi
348
+ .mockResolvedValueOnce(makeServeDep({ model: null })) // POST /serve
349
+ .mockResolvedValueOnce({ status: 'running' }); // pre-health dep status check
350
+
351
+ const fetchedUrls = [];
352
+ global.fetch = vi.fn().mockImplementation((url) => {
353
+ fetchedUrls.push(url);
354
+ return Promise.resolve({ ok: true, status: 200 });
355
+ });
356
+
357
+ const p = serveCommand(
358
+ config,
359
+ ['--image', 'ghcr.io/my-org/comfyui-custom:latest', '--gpu', 'A100'],
360
+ chalk,
361
+ );
362
+ await vi.advanceTimersByTimeAsync(5000);
363
+ await p;
364
+
365
+ expect(fetchedUrls.some(u => u.includes('/system_stats'))).toBe(true);
366
+ expect(fetchedUrls.some(u => u.includes('/models'))).toBe(false);
367
+ expect(process.exitCode).toBeFalsy();
368
+ });
369
+
370
+ it('uses --health-path when explicitly provided, overriding defaults', async () => {
371
+ api.callApi
372
+ .mockResolvedValueOnce(makeServeDep({ model: null })) // POST /serve
373
+ .mockResolvedValueOnce({ status: 'running' }); // pre-health dep status check
374
+
375
+ const fetchedUrls = [];
376
+ global.fetch = vi.fn().mockImplementation((url) => {
377
+ fetchedUrls.push(url);
378
+ return Promise.resolve({ ok: true, status: 200 });
379
+ });
380
+
381
+ const p = serveCommand(
382
+ config,
383
+ ['--image', 'ghcr.io/my-org/stable-diffusion:latest', '--health-path', '/health'],
384
+ chalk,
385
+ );
386
+ await vi.advanceTimersByTimeAsync(5000);
387
+ await p;
388
+
389
+ expect(fetchedUrls.some(u => u.includes('/health'))).toBe(true);
390
+ expect(fetchedUrls.some(u => u.includes('/models'))).toBe(false);
391
+ expect(process.exitCode).toBeFalsy();
392
+ });
393
+
394
+ it('--health-path also works for model-based serve (overrides /models default)', async () => {
395
+ api.callApi
396
+ .mockResolvedValueOnce(makeServeDep()) // POST /serve
397
+ .mockResolvedValueOnce({ status: 'running' }); // pre-health dep status check
398
+
399
+ const fetchedUrls = [];
400
+ global.fetch = vi.fn().mockImplementation((url) => {
401
+ fetchedUrls.push(url);
402
+ return Promise.resolve({ ok: true, status: 200 });
403
+ });
404
+
405
+ const p = serveCommand(
406
+ config,
407
+ ['meta-llama/Llama-3.1-8B-Instruct', '--health-path', '/v1/health'],
408
+ chalk,
409
+ );
410
+ await vi.advanceTimersByTimeAsync(5000);
411
+ await p;
412
+
413
+ expect(fetchedUrls.some(u => u.includes('/v1/health'))).toBe(true);
414
+ expect(fetchedUrls.some(u => u.endsWith('/models'))).toBe(false);
415
+ expect(process.exitCode).toBeFalsy();
416
+ });
417
+ });
418
+
419
+ // ─────────────────────────────────────────────────────────────────────────────
420
+ // 8. Routing: tier-1 unavailable → auto-expand to tier-2
421
+ // ─────────────────────────────────────────────────────────────────────────────
422
+
423
+ describe('tier-2 fallback for serve', () => {
424
+ it('expands to tier-2 when tier-1 returns NO_CAPACITY_MATCH', async () => {
425
+ const capacityErr = Object.assign(new Error('no capacity'), {
426
+ errorData: { code: 'NO_CAPACITY_MATCH' },
427
+ httpStatus: 503,
428
+ });
429
+ api.callApi
430
+ .mockRejectedValueOnce(capacityErr) // tier-1
431
+ .mockResolvedValueOnce(makeServeDep({ provider: 'vast', tier: '2' })) // tier-2
432
+ .mockResolvedValueOnce({ status: 'running' }); // pre-health
433
+
434
+ global.fetch = vi.fn().mockResolvedValue({ ok: true, status: 200, json: async () => ({}) });
435
+
436
+ const p = serveCommand(config, ['meta-llama/Llama-3.1-8B-Instruct'], chalk);
437
+ await vi.advanceTimersByTimeAsync(5000);
438
+ await p;
439
+
440
+ const postCalls = api.callApi.mock.calls.filter(c => c[1]?.method === 'POST');
441
+ expect(postCalls.length).toBeGreaterThanOrEqual(2);
442
+ expect(store.addReceipt).toHaveBeenCalledWith(expect.objectContaining({ tier: '2' }));
443
+ expect(process.exitCode).toBeFalsy();
444
+ });
445
+
446
+ it('--no-fallback prevents tier-2 expansion for serve', async () => {
447
+ const capacityErr = Object.assign(new Error('no capacity'), {
448
+ errorData: { code: 'NO_CAPACITY_MATCH' },
449
+ httpStatus: 503,
450
+ });
451
+ api.callApi.mockRejectedValueOnce(capacityErr);
452
+
453
+ await serveCommand(config, ['meta-llama/Llama-3.1-8B-Instruct', '--no-fallback'], chalk);
454
+
455
+ const postCalls = api.callApi.mock.calls.filter(c => c[1]?.method === 'POST');
456
+ expect(postCalls.length).toBe(1);
457
+ expect(process.exitCode).toBe(1);
458
+ });
459
+ });
460
+
461
+ // ─────────────────────────────────────────────────────────────────────────────
462
+ // 9. Missing endpoint URL guard
463
+ // ─────────────────────────────────────────────────────────────────────────────
464
+
465
+ describe('missing endpoint URL guard', () => {
466
+ it('exits with error when backend returns no endpoint_url', async () => {
467
+ api.callApi.mockResolvedValueOnce(makeServeDep({ endpoint_url: null, openai_base_url: null }));
468
+
469
+ await serveCommand(config, ['meta-llama/Llama-3.1-8B-Instruct'], chalk);
470
+
471
+ expect(process.exitCode).toBe(1);
472
+ expect(store.updateReceipt).toHaveBeenCalledWith('rcpt-serve-001', expect.objectContaining({
473
+ status: 'no_endpoint_url',
474
+ }));
475
+ });
476
+ });
477
+
478
+ // ─────────────────────────────────────────────────────────────────────────────
479
+ // 10. Input validation
480
+ // ─────────────────────────────────────────────────────────────────────────────
481
+
482
+ describe('input validation for serve', () => {
483
+ it('rejects missing model and --image', async () => {
484
+ await serveCommand(config, [], chalk);
485
+ expect(api.callApi).not.toHaveBeenCalled();
486
+ });
487
+
488
+ it('rejects --count 0', async () => {
489
+ await serveCommand(config, ['my/model', '--count', '0'], chalk);
490
+ expect(api.callApi).not.toHaveBeenCalled();
491
+ expect(process.exitCode).toBe(1);
492
+ });
493
+
494
+ it('rejects invalid --region', async () => {
495
+ await serveCommand(config, ['my/model', '--region', 'MOON'], chalk);
496
+ expect(api.callApi).not.toHaveBeenCalled();
497
+ expect(process.exitCode).toBe(1);
498
+ });
499
+ });
@@ -5,7 +5,7 @@ import { rmSync, existsSync } from 'fs';
5
5
  import {
6
6
  loadStore, saveStore,
7
7
  addDeployment, updateDeployment, removeDeployment, findDeployment, listDeployments,
8
- addReceipt, listReceipts,
8
+ addReceipt, updateReceipt, listReceipts,
9
9
  generateDeploymentId, generateReceiptId,
10
10
  } from '../src/store.js';
11
11
 
@@ -124,3 +124,43 @@ describe('addReceipt / listReceipts', () => {
124
124
  expect(listReceipts(3, file)).toHaveLength(3);
125
125
  });
126
126
  });
127
+
128
+ describe('updateReceipt', () => {
129
+ it('merges updates into an existing receipt', () => {
130
+ addReceipt({ receiptId: 'r-upd', action: 'badgr run', status: 'running' }, file);
131
+ updateReceipt('r-upd', { status: 'completed', finalCost: 0.012, runtimeSeconds: 42 }, file);
132
+ const [r] = listReceipts(1, file);
133
+ expect(r.status).toBe('completed');
134
+ expect(r.finalCost).toBe(0.012);
135
+ expect(r.runtimeSeconds).toBe(42);
136
+ expect(r.action).toBe('badgr run'); // preserved
137
+ });
138
+
139
+ it('returns null for unknown receipt id', () => {
140
+ expect(updateReceipt('no-such-id', { status: 'done' }, file)).toBeNull();
141
+ });
142
+
143
+ it('records a serve failure receipt with failureType=infrastructure', () => {
144
+ addReceipt({
145
+ receiptId: 'r-serve-fail',
146
+ action: 'badgr serve',
147
+ model: 'meta-llama/Llama-3.1-8B-Instruct',
148
+ gpu: 'L40S',
149
+ status: 'failed',
150
+ failureType: 'infrastructure',
151
+ createdAt: new Date().toISOString(),
152
+ }, file);
153
+ const [r] = listReceipts(1, file);
154
+ expect(r.failureType).toBe('infrastructure');
155
+ expect(r.action).toBe('badgr serve');
156
+ expect(r.status).toBe('failed');
157
+ });
158
+
159
+ it('updates a serve receipt to health_check_timeout', () => {
160
+ addReceipt({ receiptId: 'r-hc', action: 'badgr serve', status: 'provisioning' }, file);
161
+ updateReceipt('r-hc', { status: 'health_check_timeout' }, file);
162
+ const receipts = listReceipts(10, file);
163
+ const r = receipts.find(x => x.receiptId === 'r-hc');
164
+ expect(r.status).toBe('health_check_timeout');
165
+ });
166
+ });