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.
- package/README.md +37 -18
- package/package.json +1 -1
- package/src/api.js +56 -15
- package/src/badgr.js +14 -0
- package/src/commands/run.js +151 -61
- package/src/commands/serve.js +189 -43
- package/src/fallback.js +41 -32
- package/tests/commands.test.js +52 -1
- package/tests/run-lifecycle.test.js +498 -0
- package/tests/serve-lifecycle.test.js +499 -0
|
@@ -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
|
+
});
|