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