badgr-cli 1.0.37 → 1.0.39

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,551 @@
1
+ /**
2
+ * Template catalog + dispatch tests
3
+ *
4
+ * Covers:
5
+ * - Catalog integrity: all 10 templates, required fields, uniqueness
6
+ * - buildTemplateFlags: correct flags for each template type
7
+ * - parseTemplateOverrides: flag parsing, user overrides win
8
+ * - badgr serve template <name>: dispatches via serveCommand (mocked API)
9
+ * - badgr run template <name>: dispatches via runCommand (mocked API)
10
+ * - Type-mismatch routing guard (job via serve, endpoint via run)
11
+ * - Unknown template guard
12
+ */
13
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
14
+ import {
15
+ TEMPLATES,
16
+ TEMPLATE_MAP,
17
+ buildTemplateFlags,
18
+ parseTemplateOverrides,
19
+ } from '../src/catalog.js';
20
+ import { serveCommand } from '../src/commands/serve.js';
21
+ import { runCommand } from '../src/commands/run.js';
22
+
23
+ // ── Module mocks ──────────────────────────────────────────────────────────────
24
+
25
+ vi.mock('../src/api.js', () => ({
26
+ callApi: vi.fn(),
27
+ terminateDeployment: vi.fn().mockResolvedValue({}),
28
+ listDeployments: vi.fn().mockResolvedValue({ deployments: [], count: 0 }),
29
+ }));
30
+
31
+ vi.mock('../src/store.js', () => ({
32
+ addDeployment: vi.fn(),
33
+ addReceipt: vi.fn(),
34
+ updateReceipt: vi.fn(),
35
+ generateReceiptId: vi.fn(() => 'rcpt-tmpl-001'),
36
+ generateDeploymentId: vi.fn(() => 'dep-tmpl-001'),
37
+ listDeployments: vi.fn(() => []),
38
+ listReceipts: vi.fn(() => []),
39
+ findDeployment: vi.fn(() => null),
40
+ updateDeployment: vi.fn(),
41
+ removeDeployment: vi.fn(),
42
+ }));
43
+
44
+ import * as api from '../src/api.js';
45
+ import * as store from '../src/store.js';
46
+
47
+ // ── Shared helpers ────────────────────────────────────────────────────────────
48
+
49
+ const config = { apiKey: 'sk-test', baseUrl: 'https://api.test/v1' };
50
+
51
+ const chalk = {
52
+ bold: s => s, dim: s => s, red: s => s,
53
+ yellow: s => s, green: s => s, cyan: s => s,
54
+ };
55
+
56
+ function makeServeDep(overrides = {}) {
57
+ return {
58
+ deployment_id: 'dep-tmpl-001',
59
+ status: 'running',
60
+ gpu_type: 'RTX_4090',
61
+ gpu_count: 1,
62
+ cost_per_hour: 0.50,
63
+ provider: 'runpod',
64
+ receipt_id: 'rcpt-tmpl-001',
65
+ tier: '1',
66
+ endpoint_url: 'https://dep-tmpl-001.aibadgr.com',
67
+ ...overrides,
68
+ };
69
+ }
70
+
71
+ function makeRunDep(overrides = {}) {
72
+ return {
73
+ deployment_id: 'dep-tmpl-001',
74
+ status: 'running',
75
+ gpu_type: 'A100',
76
+ gpu_count: 1,
77
+ cost_per_hour: 2.50,
78
+ provider: 'runpod',
79
+ receipt_id: 'rcpt-tmpl-001',
80
+ tier: '1',
81
+ ...overrides,
82
+ };
83
+ }
84
+
85
+ beforeEach(() => {
86
+ vi.useFakeTimers();
87
+ process.exitCode = undefined;
88
+ vi.spyOn(console, 'log').mockImplementation(() => {});
89
+ vi.spyOn(console, 'error').mockImplementation(() => {});
90
+ vi.spyOn(process.stdout, 'write').mockImplementation(() => true);
91
+ vi.clearAllMocks();
92
+ store.generateReceiptId.mockReturnValue('rcpt-tmpl-001');
93
+ api.terminateDeployment.mockResolvedValue({});
94
+ });
95
+
96
+ afterEach(() => {
97
+ vi.useRealTimers();
98
+ vi.restoreAllMocks();
99
+ process.exitCode = undefined;
100
+ });
101
+
102
+ // ─────────────────────────────────────────────────────────────────────────────
103
+ // 1. Catalog integrity
104
+ // ─────────────────────────────────────────────────────────────────────────────
105
+
106
+ describe('TEMPLATES catalog', () => {
107
+ const EXPECTED_NAMES = [
108
+ 'comfyui', 'axolotl', 'unsloth', 'vllm', 'llama-cpp',
109
+ 'invokeai', 'kohya-ss', 'text-gen-webui', 'sglang', 'tgi',
110
+ 'auto1111', 'forge', 'nerfstudio', 'openfold', 'blender-render',
111
+ 'openmm', 'gromacs', 'lammps', 'diffusers', 'torchtune',
112
+ ];
113
+
114
+ it('contains exactly 20 templates', () => {
115
+ expect(TEMPLATES).toHaveLength(20);
116
+ });
117
+
118
+ it('contains all expected template names', () => {
119
+ const names = TEMPLATES.map(t => t.name);
120
+ for (const name of EXPECTED_NAMES) {
121
+ expect(names, `missing: ${name}`).toContain(name);
122
+ }
123
+ });
124
+
125
+ it('template names are unique', () => {
126
+ const names = TEMPLATES.map(t => t.name);
127
+ expect(new Set(names).size).toBe(names.length);
128
+ });
129
+
130
+ it('every template has required fields', () => {
131
+ for (const t of TEMPLATES) {
132
+ expect(t.name, `${t.name}: name`).toBeTruthy();
133
+ expect(t.title, `${t.name}: title`).toBeTruthy();
134
+ expect(t.description, `${t.name}: description`).toBeTruthy();
135
+ expect(['endpoint', 'job'], `${t.name}: type`).toContain(t.type);
136
+ expect(t.image, `${t.name}: image`).toBeTruthy();
137
+ expect(t.gpu, `${t.name}: gpu`).toBeTruthy();
138
+ expect(t.gpu_count, `${t.name}: gpu_count`).toBeGreaterThan(0);
139
+ expect(t.min_vram_gb, `${t.name}: min_vram_gb`).toBeGreaterThan(0);
140
+ }
141
+ });
142
+
143
+ it('endpoint templates have port and health_path', () => {
144
+ for (const t of TEMPLATES.filter(t => t.type === 'endpoint')) {
145
+ expect(t.port, `${t.name}: port`).toBeGreaterThan(0);
146
+ expect(t.health_path, `${t.name}: health_path`).toBeTruthy();
147
+ }
148
+ });
149
+
150
+ it('job templates do not have a port', () => {
151
+ for (const t of TEMPLATES.filter(t => t.type === 'job')) {
152
+ expect(t.port, `${t.name}: port should be absent`).toBeFalsy();
153
+ }
154
+ });
155
+
156
+ it('no env value is undefined', () => {
157
+ for (const t of TEMPLATES) {
158
+ for (const [k, v] of Object.entries(t.env ?? {})) {
159
+ expect(v, `${t.name}.env.${k}`).not.toBeUndefined();
160
+ }
161
+ }
162
+ });
163
+
164
+ it('vLLM requires ≥ 24 GB VRAM', () => {
165
+ expect(TEMPLATE_MAP['vllm'].min_vram_gb).toBeGreaterThanOrEqual(24);
166
+ });
167
+
168
+ it('SGLang requires ≥ 40 GB VRAM', () => {
169
+ expect(TEMPLATE_MAP['sglang'].min_vram_gb).toBeGreaterThanOrEqual(40);
170
+ });
171
+
172
+ it('job templates use high-VRAM GPUs', () => {
173
+ const highVram = ['A100', 'H100', 'L40S', 'A6000', 'RTX_4090'];
174
+ for (const t of TEMPLATES.filter(t => t.type === 'job')) {
175
+ expect(highVram, `${t.name}: gpu`).toContain(t.gpu);
176
+ }
177
+ });
178
+
179
+ it('TEMPLATE_MAP keys match template names', () => {
180
+ for (const [k, t] of Object.entries(TEMPLATE_MAP)) {
181
+ expect(k).toBe(t.name);
182
+ }
183
+ expect(Object.keys(TEMPLATE_MAP)).toHaveLength(20);
184
+ });
185
+ });
186
+
187
+ // ─────────────────────────────────────────────────────────────────────────────
188
+ // 2. buildTemplateFlags
189
+ // ─────────────────────────────────────────────────────────────────────────────
190
+
191
+ describe('buildTemplateFlags', () => {
192
+ it('produces --image and --gpu for every template', () => {
193
+ for (const t of TEMPLATES) {
194
+ const flags = buildTemplateFlags(t, {});
195
+ expect(flags).toContain('--image');
196
+ expect(flags).toContain(t.image);
197
+ expect(flags).toContain('--gpu');
198
+ expect(flags).toContain(t.gpu);
199
+ }
200
+ });
201
+
202
+ it('endpoint templates include --health-path', () => {
203
+ for (const t of TEMPLATES.filter(t => t.type === 'endpoint')) {
204
+ const flags = buildTemplateFlags(t, {});
205
+ expect(flags, `${t.name}: --health-path`).toContain('--health-path');
206
+ expect(flags, `${t.name}: health_path value`).toContain(t.health_path);
207
+ }
208
+ });
209
+
210
+ it('job templates do not include --health-path', () => {
211
+ for (const t of TEMPLATES.filter(t => t.type === 'job')) {
212
+ const flags = buildTemplateFlags(t, {});
213
+ expect(flags, `${t.name}: no --health-path`).not.toContain('--health-path');
214
+ }
215
+ });
216
+
217
+ it('strips placeholder env values (those starting with <)', () => {
218
+ for (const t of TEMPLATES) {
219
+ const flags = buildTemplateFlags(t, {});
220
+ const envValues = flags
221
+ .filter((_, i) => i > 0 && flags[i - 1] === '--env')
222
+ .map(kv => kv.split('=').slice(1).join('='));
223
+ for (const v of envValues) {
224
+ expect(v, `${t.name}: placeholder passed through`).not.toMatch(/^</);
225
+ }
226
+ }
227
+ });
228
+
229
+ it('vllm includes MODEL, MAX_MODEL_LEN, TENSOR_PARALLEL_SIZE env vars', () => {
230
+ const flags = buildTemplateFlags(TEMPLATE_MAP['vllm'], {});
231
+ expect(flags).toContain('MODEL=meta-llama/Llama-3.1-8B-Instruct');
232
+ expect(flags.join(' ')).toContain('MAX_MODEL_LEN=8192');
233
+ expect(flags.join(' ')).toContain('TENSOR_PARALLEL_SIZE=1');
234
+ });
235
+
236
+ it('llama-cpp includes LLAMA_ARG_HF_REPO and LLAMA_ARG_HF_FILE', () => {
237
+ const flags = buildTemplateFlags(TEMPLATE_MAP['llama-cpp'], {});
238
+ expect(flags.join(' ')).toContain('LLAMA_ARG_HF_REPO=');
239
+ expect(flags.join(' ')).toContain('LLAMA_ARG_HF_FILE=');
240
+ });
241
+
242
+ it('user --gpu override replaces template default', () => {
243
+ const flags = buildTemplateFlags(TEMPLATE_MAP['vllm'], { gpu: 'A100' });
244
+ const gpuIdx = flags.indexOf('--gpu');
245
+ expect(flags[gpuIdx + 1]).toBe('A100');
246
+ });
247
+
248
+ it('user --env override replaces template env default', () => {
249
+ const flags = buildTemplateFlags(TEMPLATE_MAP['vllm'], {
250
+ env: { MODEL: 'mistralai/Mistral-7B-v0.1' },
251
+ });
252
+ expect(flags).toContain('MODEL=mistralai/Mistral-7B-v0.1');
253
+ expect(flags).not.toContain('MODEL=meta-llama/Llama-3.1-8B-Instruct');
254
+ });
255
+
256
+ it('--max-cost is included when supplied in overrides', () => {
257
+ const flags = buildTemplateFlags(TEMPLATE_MAP['comfyui'], { maxCost: 5 });
258
+ expect(flags).toContain('--max-cost');
259
+ expect(flags).toContain('5');
260
+ });
261
+
262
+ it('--count is included only when > 1', () => {
263
+ const single = buildTemplateFlags(TEMPLATE_MAP['vllm'], {});
264
+ expect(single).not.toContain('--count');
265
+
266
+ const multi = buildTemplateFlags(TEMPLATE_MAP['vllm'], { count: 2 });
267
+ expect(multi).toContain('--count');
268
+ expect(multi).toContain('2');
269
+ });
270
+
271
+ it('--persistent flag is forwarded', () => {
272
+ const flags = buildTemplateFlags(TEMPLATE_MAP['comfyui'], { persistent: true });
273
+ expect(flags).toContain('--persistent');
274
+ });
275
+ });
276
+
277
+ // ─────────────────────────────────────────────────────────────────────────────
278
+ // 3. parseTemplateOverrides
279
+ // ─────────────────────────────────────────────────────────────────────────────
280
+
281
+ describe('parseTemplateOverrides', () => {
282
+ it('parses --max-cost', () => {
283
+ const o = parseTemplateOverrides(['--max-cost', '10']);
284
+ expect(o.maxCost).toBe(10);
285
+ });
286
+
287
+ it('parses --gpu', () => {
288
+ const o = parseTemplateOverrides(['--gpu', 'A100']);
289
+ expect(o.gpu).toBe('A100');
290
+ });
291
+
292
+ it('parses --max-runtime', () => {
293
+ const o = parseTemplateOverrides(['--max-runtime', '120']);
294
+ expect(o.maxRuntime).toBe(120);
295
+ });
296
+
297
+ it('parses multiple --env flags', () => {
298
+ const o = parseTemplateOverrides(['--env', 'MODEL=mistral', '--env', 'HF_TOKEN=abc123']);
299
+ expect(o.env.MODEL).toBe('mistral');
300
+ expect(o.env.HF_TOKEN).toBe('abc123');
301
+ });
302
+
303
+ it('parses --persistent', () => {
304
+ const o = parseTemplateOverrides(['--persistent']);
305
+ expect(o.persistent).toBe(true);
306
+ });
307
+
308
+ it('parses --no-wait', () => {
309
+ const o = parseTemplateOverrides(['--no-wait']);
310
+ expect(o.noWait).toBe(true);
311
+ });
312
+
313
+ it('ignores unknown flags without throwing', () => {
314
+ expect(() => parseTemplateOverrides(['--unknown-flag', 'value'])).not.toThrow();
315
+ });
316
+
317
+ it('returns empty env object when no --env flags given', () => {
318
+ const o = parseTemplateOverrides(['--max-cost', '5']);
319
+ expect(o.env).toEqual({});
320
+ });
321
+ });
322
+
323
+ // ─────────────────────────────────────────────────────────────────────────────
324
+ // 4. badgr serve template <name> — endpoint dispatch
325
+ // ─────────────────────────────────────────────────────────────────────────────
326
+
327
+ // Sequence for serveCommand:
328
+ // callApi('/serve', ...) → dep (via callWithFallback → callApi)
329
+ // callApi('/deployments/<id>', ...) → { status: 'running' } (pre-health check)
330
+ // global.fetch(endpoint + health_path) → { ok: true }
331
+
332
+ function setupServe(depOverrides = {}) {
333
+ api.callApi
334
+ .mockResolvedValueOnce(makeServeDep(depOverrides)) // POST /serve
335
+ .mockResolvedValueOnce({ status: 'running' }); // pre-health dep status
336
+ global.fetch = vi.fn().mockResolvedValue({ ok: true, status: 200, json: async () => ({}) });
337
+ }
338
+
339
+ describe('badgr serve template <name>', () => {
340
+ it('vllm: dispatches with correct image, max_cost_usd, and health check on /v1/models', async () => {
341
+ setupServe();
342
+ const p = serveCommand(config, ['template', 'vllm', '--max-cost', '5'], chalk);
343
+ await vi.advanceTimersByTimeAsync(5000);
344
+ await p;
345
+
346
+ const [route, opts] = api.callApi.mock.calls[0];
347
+ expect(route).toBe('/serve');
348
+ expect(opts.body.image).toBe('vllm/vllm-openai:latest');
349
+ expect(opts.body.max_cost_usd).toBe(5);
350
+ // health check fetch should use the /v1/models path from the template
351
+ const fetchUrl = global.fetch.mock.calls[0]?.[0] ?? '';
352
+ expect(fetchUrl).toContain('/v1/models');
353
+ expect(process.exitCode).toBeFalsy();
354
+ });
355
+
356
+ it('comfyui: dispatches with ComfyUI image and health check on /system_stats', async () => {
357
+ setupServe();
358
+ const p = serveCommand(config, ['template', 'comfyui', '--max-cost', '3'], chalk);
359
+ await vi.advanceTimersByTimeAsync(5000);
360
+ await p;
361
+
362
+ const [, opts] = api.callApi.mock.calls[0];
363
+ expect(opts.body.image).toBe('yanwk/comfyui-boot:cu126-megapak');
364
+ const fetchUrl = global.fetch.mock.calls[0]?.[0] ?? '';
365
+ expect(fetchUrl).toContain('/system_stats');
366
+ expect(process.exitCode).toBeFalsy();
367
+ });
368
+
369
+ it('sglang: dispatches with A100 GPU', async () => {
370
+ setupServe({ gpu_type: 'A100' });
371
+ const p = serveCommand(config, ['template', 'sglang', '--max-cost', '10'], chalk);
372
+ await vi.advanceTimersByTimeAsync(5000);
373
+ await p;
374
+
375
+ const [, opts] = api.callApi.mock.calls[0];
376
+ expect(opts.body.image).toBe('lmsysorg/sglang:latest');
377
+ expect(opts.body.gpu).toBe('A100');
378
+ expect(process.exitCode).toBeFalsy();
379
+ });
380
+
381
+ it('tgi: dispatches with TGI image', async () => {
382
+ setupServe();
383
+ const p = serveCommand(config, ['template', 'tgi', '--max-cost', '5'], chalk);
384
+ await vi.advanceTimersByTimeAsync(5000);
385
+ await p;
386
+
387
+ const [, opts] = api.callApi.mock.calls[0];
388
+ expect(opts.body.image).toBe('ghcr.io/huggingface/text-generation-inference:latest');
389
+ expect(process.exitCode).toBeFalsy();
390
+ });
391
+
392
+ it('llama-cpp: dispatches with llama-cpp image', async () => {
393
+ setupServe();
394
+ const p = serveCommand(config, ['template', 'llama-cpp', '--max-cost', '3'], chalk);
395
+ await vi.advanceTimersByTimeAsync(5000);
396
+ await p;
397
+
398
+ const [, opts] = api.callApi.mock.calls[0];
399
+ expect(opts.body.image).toBe('michaelmanleyx/llama-cpp:server-cuda');
400
+ expect(process.exitCode).toBeFalsy();
401
+ });
402
+
403
+ it('invokeai: dispatches with InvokeAI image', async () => {
404
+ setupServe();
405
+ const p = serveCommand(config, ['template', 'invokeai', '--max-cost', '5'], chalk);
406
+ await vi.advanceTimersByTimeAsync(5000);
407
+ await p;
408
+
409
+ const [, opts] = api.callApi.mock.calls[0];
410
+ expect(opts.body.image).toBe('ghcr.io/invoke-ai/invokeai:latest');
411
+ expect(process.exitCode).toBeFalsy();
412
+ });
413
+
414
+ it('kohya-ss: dispatches with Kohya SS image', async () => {
415
+ setupServe();
416
+ const p = serveCommand(config, ['template', 'kohya-ss', '--max-cost', '5'], chalk);
417
+ await vi.advanceTimersByTimeAsync(5000);
418
+ await p;
419
+
420
+ const [, opts] = api.callApi.mock.calls[0];
421
+ expect(opts.body.image).toBe('bmaltais/kohya-ss-gui:latest');
422
+ expect(process.exitCode).toBeFalsy();
423
+ });
424
+
425
+ it('text-gen-webui: dispatches with text-gen-webui image', async () => {
426
+ setupServe();
427
+ const p = serveCommand(config, ['template', 'text-gen-webui', '--max-cost', '5'], chalk);
428
+ await vi.advanceTimersByTimeAsync(5000);
429
+ await p;
430
+
431
+ const [, opts] = api.callApi.mock.calls[0];
432
+ expect(opts.body.image).toBe('atinoda/text-generation-webui:default-nightly');
433
+ expect(process.exitCode).toBeFalsy();
434
+ });
435
+
436
+ it('user --gpu override is forwarded', async () => {
437
+ setupServe({ gpu_type: 'H100' });
438
+ const p = serveCommand(config, ['template', 'vllm', '--gpu', 'H100', '--max-cost', '20'], chalk);
439
+ await vi.advanceTimersByTimeAsync(5000);
440
+ await p;
441
+
442
+ const [, opts] = api.callApi.mock.calls[0];
443
+ expect(opts.body.gpu).toBe('H100');
444
+ expect(process.exitCode).toBeFalsy();
445
+ });
446
+
447
+ it('user --env override replaces template default', async () => {
448
+ setupServe();
449
+ const p = serveCommand(
450
+ config,
451
+ ['template', 'vllm', '--env', 'MODEL=mistralai/Mistral-7B-v0.1', '--max-cost', '5'],
452
+ chalk,
453
+ );
454
+ await vi.advanceTimersByTimeAsync(5000);
455
+ await p;
456
+
457
+ const [, opts] = api.callApi.mock.calls[0];
458
+ expect(opts.body.env.MODEL).toBe('mistralai/Mistral-7B-v0.1');
459
+ expect(process.exitCode).toBeFalsy();
460
+ });
461
+ });
462
+
463
+ // ─────────────────────────────────────────────────────────────────────────────
464
+ // 5. badgr run template <name> — job dispatch
465
+ // ─────────────────────────────────────────────────────────────────────────────
466
+
467
+ // Sequence for runCommand:
468
+ // callApi('/run', ...) → dep (via callWithFallback → callApi)
469
+ // callApi('/deployments/<id>', ...) → { status: 'completed', exit_code: 0 }
470
+ // callApi('/deployments/<id>/logs', ...) → { logs: [] }
471
+
472
+ function setupRun(depOverrides = {}) {
473
+ api.callApi
474
+ .mockResolvedValueOnce(makeRunDep(depOverrides)) // POST /run
475
+ .mockResolvedValueOnce({ status: 'completed', exit_code: 0 }) // status poll
476
+ .mockResolvedValueOnce({ logs: [] }); // logs
477
+ }
478
+
479
+ describe('badgr run template <name>', () => {
480
+ it('axolotl: dispatches with A100 image, max_cost, max_runtime', async () => {
481
+ setupRun();
482
+ const p = runCommand(config, ['template', 'axolotl', '--max-cost', '20', '--max-runtime', '120'], chalk);
483
+ await vi.advanceTimersByTimeAsync(10000);
484
+ await p;
485
+
486
+ const [route, opts] = api.callApi.mock.calls[0];
487
+ expect(route).toBe('/run');
488
+ expect(opts.body.image).toBe('axolotlai/axolotl-cloud-uv:main-latest');
489
+ expect(opts.body.gpu).toBe('A100');
490
+ expect(opts.body.max_cost_usd).toBe(20);
491
+ expect(opts.body.max_runtime_seconds).toBe(7200); // 120 min * 60
492
+ expect(process.exitCode).toBeFalsy();
493
+ });
494
+
495
+ it('unsloth: dispatches with unsloth image and RTX_4090', async () => {
496
+ setupRun({ gpu_type: 'RTX_4090' });
497
+ const p = runCommand(config, ['template', 'unsloth', '--max-cost', '10', '--max-runtime', '90'], chalk);
498
+ await vi.advanceTimersByTimeAsync(10000);
499
+ await p;
500
+
501
+ const [, opts] = api.callApi.mock.calls[0];
502
+ expect(opts.body.image).toBe('unslothai/unsloth:latest');
503
+ expect(opts.body.gpu).toBe('RTX_4090');
504
+ expect(process.exitCode).toBeFalsy();
505
+ });
506
+ });
507
+
508
+ // ─────────────────────────────────────────────────────────────────────────────
509
+ // 6. Routing guards
510
+ // ─────────────────────────────────────────────────────────────────────────────
511
+
512
+ describe('routing guards', () => {
513
+ it('badgr run template <endpoint> → error with serve suggestion', async () => {
514
+ await runCommand(config, ['template', 'vllm', '--max-cost', '5'], chalk);
515
+ expect(process.exitCode).toBe(1);
516
+ expect(console.error).toHaveBeenCalledWith(
517
+ expect.stringContaining('badgr serve template vllm'),
518
+ );
519
+ });
520
+
521
+ it('badgr serve template <job> → error with run suggestion', async () => {
522
+ await serveCommand(config, ['template', 'axolotl', '--max-cost', '20'], chalk);
523
+ expect(process.exitCode).toBe(1);
524
+ expect(console.error).toHaveBeenCalledWith(
525
+ expect.stringContaining('badgr run template axolotl'),
526
+ );
527
+ });
528
+
529
+ it('badgr serve template <unknown> → error with list suggestion', async () => {
530
+ await serveCommand(config, ['template', 'doesnotexist'], chalk);
531
+ expect(process.exitCode).toBe(1);
532
+ expect(console.error).toHaveBeenCalledWith(expect.stringContaining('Unknown template'));
533
+ expect(console.error).toHaveBeenCalledWith(expect.stringContaining('badgr template list'));
534
+ });
535
+
536
+ it('badgr run template <unknown> → error with list suggestion', async () => {
537
+ await runCommand(config, ['template', 'alsonotreal'], chalk);
538
+ expect(process.exitCode).toBe(1);
539
+ expect(console.error).toHaveBeenCalledWith(expect.stringContaining('Unknown template'));
540
+ });
541
+
542
+ it('badgr serve template with no name → error', async () => {
543
+ await serveCommand(config, ['template'], chalk);
544
+ expect(process.exitCode).toBe(1);
545
+ });
546
+
547
+ it('badgr run template with no name → error', async () => {
548
+ await runCommand(config, ['template'], chalk);
549
+ expect(process.exitCode).toBe(1);
550
+ });
551
+ });
@@ -0,0 +1,56 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+
3
+ // Verify `badgr workload run` (rerun a saved workflow) resolves name→id and
4
+ // merges --set / --max-cost / --max-runtime into the POST body the server
5
+ // expects, without provisioning a GPU.
6
+ const calls = [];
7
+ vi.mock('../src/api.js', () => ({
8
+ callApi: vi.fn(async (path, opts = {}) => {
9
+ calls.push({ path, opts });
10
+ if (path.startsWith('/workloads?')) {
11
+ return { workloads: [{ name: 'mini-train', workload_id: 'wl_abc' }], total: 1 };
12
+ }
13
+ if (path === '/workloads/wl_abc/run') {
14
+ return { job_id: 'job_1', status_url: 'https://aibadgr.com/v1/jobs/job_1', estimated_cost_usd: 1.23 };
15
+ }
16
+ return {};
17
+ }),
18
+ }));
19
+
20
+ const { workloadCommand } = await import('../src/commands/workload.js');
21
+ const chalk = new Proxy({}, { get: () => (s) => s });
22
+ const config = { apiKey: 'k', baseUrl: 'https://aibadgr.com/v1' };
23
+
24
+ beforeEach(() => {
25
+ calls.length = 0;
26
+ vi.spyOn(console, 'log').mockImplementation(() => {});
27
+ });
28
+
29
+ describe('badgr workload run (rerun saved workflow)', () => {
30
+ it('resolves name→id and merges --set / --max-cost / --max-runtime into the rerun body', async () => {
31
+ await workloadCommand(
32
+ config,
33
+ ['run', 'mini-train', '--set', 'gpu=H100', '--max-cost', '8', '--max-runtime', '30'],
34
+ chalk,
35
+ );
36
+
37
+ const resolve = calls.find(c => c.path.startsWith('/workloads?'));
38
+ expect(resolve, 'should look up workload by name').toBeTruthy();
39
+ expect(resolve.path.startsWith('/v1/')).toBe(false);
40
+
41
+ const run = calls.find(c => c.path === '/workloads/wl_abc/run');
42
+ expect(run, 'should POST to the rerun endpoint').toBeTruthy();
43
+ expect(run.opts.method).toBe('POST');
44
+ expect(run.opts.body).toEqual({
45
+ config_overrides: { gpu: 'H100' },
46
+ max_cost: 8,
47
+ max_runtime_minutes: 30,
48
+ });
49
+ });
50
+
51
+ it('sends only config_overrides when no caps are passed', async () => {
52
+ await workloadCommand(config, ['run', 'mini-train', '--set', 'steps=50'], chalk);
53
+ const run = calls.find(c => c.path === '/workloads/wl_abc/run');
54
+ expect(run.opts.body).toEqual({ config_overrides: { steps: '50' } });
55
+ });
56
+ });
@@ -239,7 +239,7 @@ describe('comfyuiCommand', () => {
239
239
  const bodyBuilder = fallback.callWithFallback.mock.calls[0][2];
240
240
  const body = bodyBuilder();
241
241
  expect(body.env.COMFYUI_WORKFLOW_B64).toBe(Buffer.from(wfContent).toString('base64'));
242
- expect(body.image).toBe('yanwk/comfyui-boot:latest');
242
+ expect(body.image).toBe('yanwk/comfyui-boot:cu126-megapak');
243
243
  });
244
244
 
245
245
  it('skips health check when --no-wait', async () => {
@@ -0,0 +1,46 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+
3
+ // Capture every path passed to callApi so we can assert the CLI never builds a
4
+ // doubled `/v1/v1/...` URL. baseUrl already ends in `/v1`, so command paths must
5
+ // be unprefixed (e.g. `/workspaces`, not `/v1/workspaces`).
6
+ const calls = [];
7
+ vi.mock('../src/api.js', () => ({
8
+ callApi: vi.fn(async (path) => {
9
+ calls.push(path);
10
+ if (path.startsWith('/workspaces')) return { workspaces: [], total: 0 };
11
+ if (path.startsWith('/workloads')) return { workloads: [], total: 0 };
12
+ return {};
13
+ }),
14
+ }));
15
+
16
+ const { workspaceCommand } = await import('../src/commands/workspace.js');
17
+ const { workloadCommand } = await import('../src/commands/workload.js');
18
+
19
+ // chalk stub: any style method returns its first argument unchanged.
20
+ const chalk = new Proxy({}, { get: () => (s) => s });
21
+ const config = { apiKey: 'test-key', baseUrl: 'https://aibadgr.com/v1' };
22
+
23
+ function assertNoDoubleV1() {
24
+ expect(calls.length).toBeGreaterThan(0);
25
+ for (const path of calls) {
26
+ expect(path.startsWith('/v1/')).toBe(false);
27
+ expect(`${config.baseUrl}${path}`).not.toContain('/v1/v1/');
28
+ }
29
+ }
30
+
31
+ beforeEach(() => {
32
+ calls.length = 0;
33
+ vi.spyOn(console, 'log').mockImplementation(() => {});
34
+ });
35
+
36
+ describe('CLI workload/workspace request paths', () => {
37
+ it('workspace list targets /workspaces (no doubled /v1)', async () => {
38
+ await workspaceCommand(config, ['list'], chalk);
39
+ assertNoDoubleV1();
40
+ });
41
+
42
+ it('workload list targets /workloads (no doubled /v1)', async () => {
43
+ await workloadCommand(config, ['list'], chalk);
44
+ assertNoDoubleV1();
45
+ });
46
+ });