badgr-cli 1.1.1 → 1.1.2

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.
Files changed (66) hide show
  1. package/LICENSE +207 -0
  2. package/README.md +9 -2
  3. package/package.json +44 -2
  4. package/src/api.js +16 -0
  5. package/src/badgr.js +2 -2
  6. package/src/commands/batch.js +11 -0
  7. package/src/commands/comfyui.js +31 -15
  8. package/src/commands/embed.js +13 -10
  9. package/src/commands/launch.js +8 -1
  10. package/src/commands/login.js +75 -20
  11. package/src/commands/run.js +45 -13
  12. package/src/commands/sbatch.js +6 -1
  13. package/src/commands/serve.js +44 -30
  14. package/src/commands/train.js +8 -12
  15. package/src/commands/transcribe.js +13 -10
  16. package/src/envFlag.js +10 -0
  17. package/src/onboarding.js +8 -1
  18. package/src/progress.js +48 -0
  19. package/tests/agent-images.test.js +0 -17
  20. package/tests/api.test.js +0 -168
  21. package/tests/artifactDownload.test.js +0 -113
  22. package/tests/artifacts.test.js +0 -168
  23. package/tests/batch.test.js +0 -641
  24. package/tests/browser.test.js +0 -51
  25. package/tests/capacity.test.js +0 -68
  26. package/tests/commands.test.js +0 -417
  27. package/tests/config.test.js +0 -96
  28. package/tests/connect.test.js +0 -83
  29. package/tests/detect.test.js +0 -191
  30. package/tests/down.test.js +0 -150
  31. package/tests/errors.test.js +0 -130
  32. package/tests/fallback-timeout.test.js +0 -41
  33. package/tests/fanout.test.js +0 -124
  34. package/tests/gpu-doctor-classifiers.test.js +0 -402
  35. package/tests/gpu-doctor-doctor.test.js +0 -304
  36. package/tests/gpu-doctor-probe-cache.test.js +0 -110
  37. package/tests/gpu-doctor-probes.test.js +0 -257
  38. package/tests/heartbeat.test.js +0 -70
  39. package/tests/job-progress-poll.test.js +0 -136
  40. package/tests/launch-command-argv.test.js +0 -93
  41. package/tests/launch-readiness.test.js +0 -403
  42. package/tests/launch.test.js +0 -440
  43. package/tests/onboarding.test.js +0 -134
  44. package/tests/productized-dry-run.test.js +0 -141
  45. package/tests/productized-runners.test.js +0 -237
  46. package/tests/pull.test.js +0 -266
  47. package/tests/rerun.test.js +0 -94
  48. package/tests/restart.test.js +0 -88
  49. package/tests/router.test.js +0 -98
  50. package/tests/run-lifecycle.test.js +0 -1054
  51. package/tests/sbatch.test.js +0 -190
  52. package/tests/secrets.test.js +0 -16
  53. package/tests/serve-apps.test.js +0 -189
  54. package/tests/serve-lifecycle.test.js +0 -931
  55. package/tests/slurm.test.js +0 -77
  56. package/tests/spec.test.js +0 -201
  57. package/tests/status.test.js +0 -73
  58. package/tests/store.test.js +0 -187
  59. package/tests/task.test.js +0 -109
  60. package/tests/template.test.js +0 -556
  61. package/tests/train-lora-dataset.test.js +0 -176
  62. package/tests/upload.test.js +0 -79
  63. package/tests/workload-rerun.test.js +0 -56
  64. package/tests/workload-spec.test.js +0 -180
  65. package/tests/workload-templates.test.js +0 -865
  66. package/tests/workload-workspace-paths.test.js +0 -46
@@ -1,641 +0,0 @@
1
- /**
2
- * badgr batch — /run body shape, missing-artifact-path detection, and
3
- * `badgr batch compare` regression logic against literal metrics.json
4
- * fixtures (satisfies test scenario 5 without needing a live run).
5
- */
6
- import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
7
- import { mkdtempSync, writeFileSync, mkdirSync, rmSync } from 'fs';
8
- import { tmpdir } from 'os';
9
- import { join } from 'path';
10
-
11
- vi.mock('../src/store.js', () => ({
12
- selectedComputeFromDeployment: (dep) => ({
13
- gpu: dep.gpu_type ?? null,
14
- gpuCount: dep.gpu_count ?? null,
15
- vcpus: dep.selected_vcpus ?? null,
16
- ramGb: dep.selected_ram_gb ?? null,
17
- vramGb: dep.selected_vram_gb ?? null,
18
- }),
19
- addReceipt: vi.fn(),
20
- updateReceipt: vi.fn(),
21
- generateReceiptId: vi.fn(() => 'rcpt-batch-001'),
22
- loadStore: vi.fn(() => ({ receipts: [], deployments: [] })),
23
- }));
24
-
25
- vi.mock('../src/api.js', () => ({
26
- callApi: vi.fn(),
27
- uploadBlob: vi.fn(),
28
- }));
29
-
30
- vi.mock('../src/fallback.js', async (importOriginal) => {
31
- const actual = await importOriginal();
32
- return { ...actual, callWithFallback: vi.fn() };
33
- });
34
-
35
- vi.mock('../src/batch.js', () => ({
36
- monitorBatchJob: vi.fn(() => Promise.resolve({ status: 'succeeded', exitCode: 0, runtimeMs: 42000, reason: 'complete' })),
37
- fmtRuntime: (ms) => `${Math.round(ms / 1000)}s`,
38
- }));
39
-
40
- vi.mock('archiver', () => ({
41
- default: () => {
42
- let out;
43
- return {
44
- on: () => {},
45
- pipe: (o) => { out = o; },
46
- directory: () => {},
47
- file: () => {},
48
- finalize: () => { out.end(); },
49
- };
50
- },
51
- }));
52
-
53
- import {
54
- batchCommand,
55
- displayFailureReason,
56
- missingOutputPaths,
57
- readMetricFile,
58
- nextArchiveTmpFile,
59
- } from '../src/commands/batch.js';
60
- import * as store from '../src/store.js';
61
- import * as api from '../src/api.js';
62
- import * as fallback from '../src/fallback.js';
63
-
64
- const chalk = {
65
- bold: s => s, dim: s => s, red: s => s, yellow: s => s, green: s => s, cyan: s => s,
66
- };
67
-
68
- const config = { apiKey: 'sk-test', baseUrl: 'https://api.test/v1' };
69
-
70
- let dir;
71
- beforeEach(() => {
72
- dir = mkdtempSync(join(tmpdir(), 'badgr-batch-cmd-test-'));
73
- process.exitCode = undefined;
74
- vi.spyOn(console, 'log').mockImplementation(() => {});
75
- vi.spyOn(console, 'error').mockImplementation(() => {});
76
- vi.spyOn(process.stdout, 'write').mockImplementation(() => true);
77
- vi.clearAllMocks();
78
- store.loadStore.mockReturnValue({ receipts: [], deployments: [] });
79
- });
80
-
81
- afterEach(() => {
82
- rmSync(dir, { recursive: true, force: true });
83
- vi.restoreAllMocks();
84
- });
85
-
86
- // ---------------------------------------------------------------------------
87
- // displayFailureReason
88
- // ---------------------------------------------------------------------------
89
-
90
- describe('displayFailureReason', () => {
91
- it('maps max_runtime to runtime_limit_exceeded', () => {
92
- expect(displayFailureReason('max_runtime')).toBe('runtime_limit_exceeded');
93
- });
94
- it('maps max_cost to cost_cap_exceeded', () => {
95
- expect(displayFailureReason('max_cost')).toBe('cost_cap_exceeded');
96
- });
97
- it('passes through unknown reasons unchanged', () => {
98
- expect(displayFailureReason('something_else')).toBe('something_else');
99
- });
100
- it('returns null for no reason', () => {
101
- expect(displayFailureReason(null)).toBeNull();
102
- expect(displayFailureReason(undefined)).toBeNull();
103
- });
104
- });
105
-
106
- // ---------------------------------------------------------------------------
107
- // missingOutputPaths / readMetricFile
108
- // ---------------------------------------------------------------------------
109
-
110
- describe('nextArchiveTmpFile', () => {
111
- it('never returns the same path twice, even called back-to-back in the same millisecond', () => {
112
- // Regression test: the original implementation used only Date.now() for
113
- // the temp filename, which collides when --fan-out builds multiple
114
- // archives in the same millisecond (routine once concurrency > 1),
115
- // corrupting one task's archive out from under another and silently
116
- // dropping tasks. A monotonic counter fixes it — assert directly rather
117
- // than through the full CLI/filesystem, since the guarantee is about the
118
- // string, not the I/O around it.
119
- const paths = new Set();
120
- for (let i = 0; i < 500; i++) paths.add(nextArchiveTmpFile());
121
- expect(paths.size).toBe(500);
122
- });
123
-
124
- it('always includes the badgr-batch-inputs prefix and .tar.gz suffix', () => {
125
- const p = nextArchiveTmpFile();
126
- expect(p).toMatch(/badgr-batch-inputs-\d+-\d+\.tar\.gz$/);
127
- });
128
- });
129
-
130
- describe('missingOutputPaths', () => {
131
- it('reports declared paths that do not exist under the extraction dir', () => {
132
- mkdirSync(join(dir, 'outputs'), { recursive: true });
133
- writeFileSync(join(dir, 'outputs', 'metrics.json'), '{}');
134
- const missing = missingOutputPaths(dir, ['/outputs/metrics.json', '/outputs/videos', '/outputs/logs']);
135
- expect(missing).toEqual(['/outputs/videos', '/outputs/logs']);
136
- });
137
-
138
- it('reports nothing missing when all declared paths exist', () => {
139
- mkdirSync(join(dir, 'outputs', 'videos'), { recursive: true });
140
- writeFileSync(join(dir, 'outputs', 'metrics.json'), '{}');
141
- const missing = missingOutputPaths(dir, ['/outputs/metrics.json', '/outputs/videos']);
142
- expect(missing).toEqual([]);
143
- });
144
- });
145
-
146
- describe('readMetricFile', () => {
147
- it('reads and parses a metrics JSON file', () => {
148
- mkdirSync(join(dir, 'outputs'), { recursive: true });
149
- writeFileSync(join(dir, 'outputs', 'metrics.json'), JSON.stringify({ pass_rate: 0.9 }));
150
- expect(readMetricFile(dir, '/outputs/metrics.json')).toEqual({ pass_rate: 0.9 });
151
- });
152
-
153
- it('returns null when the file does not exist', () => {
154
- expect(readMetricFile(dir, '/outputs/metrics.json')).toBeNull();
155
- });
156
-
157
- it('returns null on malformed JSON', () => {
158
- mkdirSync(join(dir, 'outputs'), { recursive: true });
159
- writeFileSync(join(dir, 'outputs', 'metrics.json'), 'not json');
160
- expect(readMetricFile(dir, '/outputs/metrics.json')).toBeNull();
161
- });
162
- });
163
-
164
- // ---------------------------------------------------------------------------
165
- // badgr batch run — /run body shape (image/command/env/caps)
166
- // ---------------------------------------------------------------------------
167
-
168
- describe('badgr batch run — /run request shape', () => {
169
- it('builds the /run body from workload.yml with inputs/outputs env wiring', async () => {
170
- writeFileSync(join(dir, 'policy.py'), 'def act(obs): return 0');
171
- mkdirSync(join(dir, 'scenarios'));
172
- writeFileSync(join(dir, 'scenarios', 's0.json'), '{}');
173
- const yamlPath = join(dir, 'workload.yml');
174
- writeFileSync(yamlPath, `
175
- name: toy-robot-eval
176
- image: badgr/physical-ai-eval-lite:latest
177
- command: [python, run_eval.py, --policy, /inputs/policy.py, --scenarios, /inputs/scenarios]
178
- inputs:
179
- - ./policy.py:/inputs/policy.py
180
- - ./scenarios:/inputs/scenarios
181
- outputs:
182
- - /outputs/metrics.json
183
- - /outputs/videos
184
- - /outputs/logs
185
- max_cost: 20
186
- max_runtime_minutes: 60
187
- success_metric:
188
- file: /outputs/metrics.json
189
- key: pass_rate
190
- higher_is_better: true
191
- `);
192
-
193
- let capturedBody;
194
- fallback.callWithFallback.mockImplementation((endpoint, callOpts, buildBody) => {
195
- capturedBody = buildBody();
196
- return Promise.resolve({
197
- deployment_id: 'dep-abc123', receipt_id: 'rcpt-1', gpu_type: 'RTX_4090',
198
- gpu_count: 1, cost_per_hour: 0.5, provider: 'runpod', tier: '1', status: 'running',
199
- });
200
- });
201
-
202
- api.callApi.mockImplementation((path) => {
203
- if (path.endsWith('/artifacts')) return Promise.reject(new Error('no artifacts yet'));
204
- if (path.startsWith('/deployments/')) {
205
- return Promise.resolve({
206
- status: 'succeeded', failure_reason: null, teardown_ok: 'ok',
207
- runtime_seconds: 42, accrued_cost_usd: 0.01, provider: 'runpod',
208
- });
209
- }
210
- return Promise.resolve({});
211
- });
212
-
213
- api.uploadBlob.mockResolvedValue({ code_uri: 'https://cdn.example.com/inputs.tar.gz', upload_id: 'up_1' });
214
-
215
- await batchCommand(config, ['run', yamlPath], chalk);
216
-
217
- expect(capturedBody.image).toBe('badgr/physical-ai-eval-lite:latest');
218
- expect(capturedBody.command).toEqual(['python', 'run_eval.py', '--policy', '/inputs/policy.py', '--scenarios', '/inputs/scenarios']);
219
- expect(capturedBody.max_cost_usd).toBe(20);
220
- expect(capturedBody.max_runtime_seconds).toBe(3600);
221
- expect(capturedBody.custom_image).toBe(true);
222
- expect(capturedBody.env.BADGR_INPUTS_ARCHIVE_URL).toBe('https://cdn.example.com/inputs.tar.gz');
223
- expect(JSON.parse(capturedBody.env.BADGR_OUTPUT_PATHS_JSON)).toEqual([
224
- '/outputs/metrics.json', '/outputs/videos', '/outputs/logs',
225
- ]);
226
- expect(store.addReceipt).toHaveBeenCalledWith(expect.objectContaining({ workloadShape: 'container-batch' }));
227
- });
228
-
229
- it('rejects a workload.yml missing required fields before touching the network', async () => {
230
- const yamlPath = join(dir, 'bad.yml');
231
- writeFileSync(yamlPath, 'image: busybox\n');
232
- await batchCommand(config, ['run', yamlPath], chalk);
233
- expect(process.exitCode).toBe(1);
234
- expect(fallback.callWithFallback).not.toHaveBeenCalled();
235
- });
236
-
237
- it('--dry-run never submits — regression: this flag was previously silently ignored, submitting real GPU jobs anyway', async () => {
238
- // Found live: `badgr batch run workload.yml --dry-run` (and the
239
- // --fan-out form below) provisioned real GPU deployments despite
240
- // --dry-run, because the flag was never parsed or checked anywhere in
241
- // this file — every real submission this test guards against actually
242
- // happened once, on real infrastructure, before this fix.
243
- const yamlPath = join(dir, 'workload.yml');
244
- writeFileSync(yamlPath, 'name: t\nimage: busybox\ncommand: [echo, hi]\nmax_cost: 1\nmax_runtime_minutes: 5\n');
245
-
246
- await batchCommand(config, ['run', yamlPath, '--dry-run'], chalk);
247
-
248
- expect(process.exitCode).toBeUndefined();
249
- expect(fallback.callWithFallback).not.toHaveBeenCalled();
250
- expect(api.uploadBlob).not.toHaveBeenCalled();
251
- expect(store.addReceipt).not.toHaveBeenCalled();
252
- });
253
-
254
- it('builds a body with no BADGR_* env vars when inputs/outputs are omitted', async () => {
255
- const yamlPath = join(dir, 'workload.yml');
256
- writeFileSync(yamlPath, `
257
- name: simple-job
258
- image: busybox
259
- command: [echo, hi]
260
- max_cost: 1
261
- max_runtime_minutes: 5
262
- `);
263
-
264
- let capturedBody;
265
- fallback.callWithFallback.mockImplementation((endpoint, callOpts, buildBody) => {
266
- capturedBody = buildBody();
267
- return Promise.resolve({
268
- deployment_id: 'dep-xyz', receipt_id: 'rcpt-2', gpu_type: 'RTX_4090',
269
- gpu_count: 1, cost_per_hour: 0.1, provider: 'runpod', tier: '1', status: 'running',
270
- });
271
- });
272
- api.callApi.mockImplementation((path) => {
273
- if (path.startsWith('/deployments/')) {
274
- return Promise.resolve({ status: 'succeeded', teardown_ok: 'ok' });
275
- }
276
- return Promise.resolve({});
277
- });
278
-
279
- await batchCommand(config, ['run', yamlPath], chalk);
280
-
281
- expect(capturedBody.env.BADGR_INPUTS_ARCHIVE_URL).toBeUndefined();
282
- expect(capturedBody.env.BADGR_OUTPUT_PATHS_JSON).toBeUndefined();
283
- });
284
- });
285
-
286
- // ---------------------------------------------------------------------------
287
- // badgr batch run --fan-out <dir> — "run one program across many inputs"
288
- // ---------------------------------------------------------------------------
289
-
290
- describe('badgr batch run --fan-out', () => {
291
- function writeFanOutYaml(dirPath, extra = '') {
292
- const yamlPath = join(dirPath, 'workload.yml');
293
- writeFileSync(yamlPath, `
294
- name: screen
295
- image: badgr/screen:latest
296
- command: [python, screen.py, --input, /inputs/scenario.json]
297
- inputs:
298
- - ./scenarios/PLACEHOLDER.json:/inputs/scenario.json
299
- outputs:
300
- - /outputs/result.json
301
- max_cost: 1
302
- max_runtime_minutes: 10
303
- ${extra}
304
- `);
305
- return yamlPath;
306
- }
307
-
308
- it('submits one deployment per file in the fan-out directory, in parallel', async () => {
309
- mkdirSync(join(dir, 'scenarios'));
310
- writeFileSync(join(dir, 'scenarios', 's0.json'), '{}');
311
- writeFileSync(join(dir, 'scenarios', 's1.json'), '{}');
312
- writeFileSync(join(dir, 'scenarios', 'PLACEHOLDER.json'), '{}'); // satisfies inputs: parse; overridden per task
313
- const yamlPath = writeFanOutYaml(dir);
314
-
315
- let callCount = 0;
316
- const capturedBodies = [];
317
- fallback.callWithFallback.mockImplementation((endpoint, callOpts, buildBody) => {
318
- callCount += 1;
319
- capturedBodies.push(buildBody());
320
- return Promise.resolve({
321
- deployment_id: `dep-${callCount}`, receipt_id: `rcpt-${callCount}`, gpu_type: 'RTX_4090',
322
- gpu_count: 1, cost_per_hour: 0.2, provider: 'runpod', tier: '1', status: 'running',
323
- });
324
- });
325
- api.callApi.mockImplementation((path) => {
326
- if (path.startsWith('/deployments/')) {
327
- return Promise.resolve({ status: 'succeeded', teardown_ok: 'ok', runtime_seconds: 3, accrued_cost_usd: 0.01 });
328
- }
329
- return Promise.resolve({});
330
- });
331
- api.uploadBlob.mockResolvedValue({ code_uri: 'https://cdn.example.com/inputs.tar.gz', upload_id: 'up_1' });
332
-
333
- await batchCommand(config, ['run', yamlPath, '--fan-out', join(dir, 'scenarios')], chalk);
334
-
335
- // 3 files in scenarios/ (PLACEHOLDER.json, s0.json, s1.json), one deployment each.
336
- expect(callCount).toBe(3);
337
- expect(store.addReceipt).toHaveBeenCalledTimes(3);
338
- const names = capturedBodies.map(b => b.name);
339
- expect(names.every(n => n.startsWith('screen-'))).toBe(true);
340
- expect(new Set(names).size).toBe(3); // distinct per-file names
341
- expect(capturedBodies[0].env.BADGR_FANOUT_INPUT).toBeDefined();
342
- expect(store.addReceipt).toHaveBeenCalledWith(expect.objectContaining({ workloadShape: 'fan-out' }));
343
- expect(process.exitCode).toBeUndefined();
344
- });
345
-
346
- it('rejects --fan-out when workload.yml declares more than one input', async () => {
347
- mkdirSync(join(dir, 'scenarios'));
348
- writeFileSync(join(dir, 'scenarios', 's0.json'), '{}');
349
- const yamlPath = join(dir, 'workload.yml');
350
- writeFileSync(yamlPath, `
351
- name: multi-input
352
- image: busybox
353
- command: [echo, hi]
354
- inputs:
355
- - ./a.txt:/inputs/a.txt
356
- - ./b.txt:/inputs/b.txt
357
- max_cost: 1
358
- max_runtime_minutes: 5
359
- `);
360
- writeFileSync(join(dir, 'a.txt'), 'x');
361
- writeFileSync(join(dir, 'b.txt'), 'x');
362
-
363
- await batchCommand(config, ['run', yamlPath, '--fan-out', join(dir, 'scenarios')], chalk);
364
- expect(process.exitCode).toBe(1);
365
- expect(fallback.callWithFallback).not.toHaveBeenCalled();
366
- });
367
-
368
- it('rejects a --fan-out path that does not exist', async () => {
369
- const yamlPath = writeFanOutYaml(dir);
370
- await batchCommand(config, ['run', yamlPath, '--fan-out', join(dir, 'does-not-exist')], chalk);
371
- expect(process.exitCode).toBe(1);
372
- expect(fallback.callWithFallback).not.toHaveBeenCalled();
373
- });
374
-
375
- it('--dry-run with --fan-out never submits any task — same regression as the non-fan-out case', async () => {
376
- mkdirSync(join(dir, 'scenarios'));
377
- writeFileSync(join(dir, 'scenarios', 's0.json'), '{}');
378
- writeFileSync(join(dir, 'scenarios', 's1.json'), '{}');
379
- writeFileSync(join(dir, 'scenarios', 'PLACEHOLDER.json'), '{}');
380
- const yamlPath = writeFanOutYaml(dir);
381
-
382
- await batchCommand(config, ['run', yamlPath, '--fan-out', join(dir, 'scenarios'), '--dry-run'], chalk);
383
-
384
- expect(process.exitCode).toBeUndefined();
385
- expect(fallback.callWithFallback).not.toHaveBeenCalled();
386
- expect(api.uploadBlob).not.toHaveBeenCalled();
387
- expect(store.addReceipt).not.toHaveBeenCalled();
388
- });
389
-
390
- it('rejects an empty --fan-out directory', async () => {
391
- mkdirSync(join(dir, 'empty'));
392
- const yamlPath = writeFanOutYaml(dir);
393
- await batchCommand(config, ['run', yamlPath, '--fan-out', join(dir, 'empty')], chalk);
394
- expect(process.exitCode).toBe(1);
395
- expect(fallback.callWithFallback).not.toHaveBeenCalled();
396
- });
397
-
398
- it('reports a non-zero exit code when at least one fan-out task fails', async () => {
399
- mkdirSync(join(dir, 'scenarios'));
400
- writeFileSync(join(dir, 'scenarios', 's0.json'), '{}');
401
- writeFileSync(join(dir, 'scenarios', 's1.json'), '{}');
402
- const yamlPath = writeFanOutYaml(dir);
403
-
404
- const { monitorBatchJob } = await import('../src/batch.js');
405
- let monitorCall = 0;
406
- monitorBatchJob.mockImplementation(() => {
407
- monitorCall += 1;
408
- return Promise.resolve(
409
- monitorCall === 1
410
- ? { status: 'failed', exitCode: 1, runtimeMs: 100, reason: 'infrastructure' }
411
- : { status: 'succeeded', exitCode: 0, runtimeMs: 100, reason: 'complete' },
412
- );
413
- });
414
-
415
- let depCall = 0;
416
- fallback.callWithFallback.mockImplementation((endpoint, callOpts, buildBody) => {
417
- depCall += 1;
418
- return Promise.resolve({
419
- deployment_id: `dep-${depCall}`, receipt_id: `rcpt-${depCall}`, gpu_type: 'RTX_4090',
420
- gpu_count: 1, cost_per_hour: 0.2, provider: 'runpod', tier: '1', status: 'running',
421
- });
422
- });
423
- api.callApi.mockImplementation((path) => {
424
- if (path.startsWith('/deployments/dep-1')) return Promise.resolve({ status: 'failed', teardown_ok: 'ok' });
425
- if (path.startsWith('/deployments/')) return Promise.resolve({ status: 'succeeded', teardown_ok: 'ok' });
426
- return Promise.resolve({});
427
- });
428
- api.uploadBlob.mockResolvedValue({ code_uri: 'https://cdn.example.com/inputs.tar.gz', upload_id: 'up_1' });
429
-
430
- await batchCommand(config, ['run', yamlPath, '--fan-out', join(dir, 'scenarios')], chalk);
431
- expect(process.exitCode).toBe(1);
432
- });
433
-
434
- it('prints a ready-to-copy --only rerun command listing the failed inputs', async () => {
435
- mkdirSync(join(dir, 'scenarios'));
436
- writeFileSync(join(dir, 'scenarios', 's0.json'), '{}');
437
- writeFileSync(join(dir, 'scenarios', 's1.json'), '{}');
438
- const yamlPath = writeFanOutYaml(dir);
439
-
440
- const { monitorBatchJob } = await import('../src/batch.js');
441
- monitorBatchJob
442
- .mockResolvedValueOnce({ status: 'failed', exitCode: 1, runtimeMs: 100, reason: 'infrastructure' })
443
- .mockResolvedValueOnce({ status: 'succeeded', exitCode: 0, runtimeMs: 100, reason: 'complete' });
444
- let depCall = 0;
445
- fallback.callWithFallback.mockImplementation(() => {
446
- depCall += 1;
447
- return Promise.resolve({
448
- deployment_id: `dep-${depCall}`, receipt_id: `rcpt-${depCall}`, gpu_type: 'RTX_4090',
449
- gpu_count: 1, cost_per_hour: 0.2, provider: 'runpod', tier: '1', status: 'running',
450
- });
451
- });
452
- api.callApi.mockImplementation((path) => {
453
- if (path.startsWith('/deployments/dep-1')) return Promise.resolve({ status: 'failed', teardown_ok: 'ok' });
454
- if (path.startsWith('/deployments/')) return Promise.resolve({ status: 'succeeded', teardown_ok: 'ok' });
455
- return Promise.resolve({});
456
- });
457
- api.uploadBlob.mockResolvedValue({ code_uri: 'https://cdn.example.com/inputs.tar.gz', upload_id: 'up_1' });
458
-
459
- const logs = [];
460
- const origLog = console.log;
461
- console.log = (...args) => { logs.push(args.join(' ')); };
462
- // --max-concurrency 1 makes submit/monitor order deterministic (s0 fully
463
- // completes before s1 starts), so the first mocked monitor result (failed)
464
- // is guaranteed to land on s0 rather than depending on scheduling order.
465
- await batchCommand(config, ['run', yamlPath, '--fan-out', join(dir, 'scenarios'), '--max-concurrency', '1'], chalk);
466
- console.log = origLog;
467
-
468
- expect(process.exitCode).toBe(1);
469
- expect(logs.some(l => l.includes('--only s0.json'))).toBe(true);
470
- });
471
-
472
- it('--only restricts the fan-out to the named files', async () => {
473
- mkdirSync(join(dir, 'scenarios'));
474
- writeFileSync(join(dir, 'scenarios', 's0.json'), '{}');
475
- writeFileSync(join(dir, 'scenarios', 's1.json'), '{}');
476
- writeFileSync(join(dir, 'scenarios', 's2.json'), '{}');
477
- const yamlPath = writeFanOutYaml(dir);
478
-
479
- let callCount = 0;
480
- fallback.callWithFallback.mockImplementation(() => {
481
- callCount += 1;
482
- return Promise.resolve({
483
- deployment_id: `dep-${callCount}`, receipt_id: `rcpt-${callCount}`, gpu_type: 'RTX_4090',
484
- gpu_count: 1, cost_per_hour: 0.2, provider: 'runpod', tier: '1', status: 'running',
485
- });
486
- });
487
- api.callApi.mockResolvedValue({ status: 'succeeded', teardown_ok: 'ok', runtime_seconds: 1, accrued_cost_usd: 0.01 });
488
- api.uploadBlob.mockResolvedValue({ code_uri: 'https://cdn.example.com/inputs.tar.gz', upload_id: 'up_1' });
489
-
490
- await batchCommand(config, ['run', yamlPath, '--fan-out', join(dir, 'scenarios'), '--only', 's0.json,s2.json'], chalk);
491
-
492
- expect(callCount).toBe(2); // not 3 — s1.json excluded
493
- expect(process.exitCode).toBeUndefined();
494
- });
495
-
496
- it('--only rejects a filename that does not exist in the fan-out directory', async () => {
497
- mkdirSync(join(dir, 'scenarios'));
498
- writeFileSync(join(dir, 'scenarios', 's0.json'), '{}');
499
- const yamlPath = writeFanOutYaml(dir);
500
-
501
- await batchCommand(config, ['run', yamlPath, '--fan-out', join(dir, 'scenarios'), '--only', 'does-not-exist.json'], chalk);
502
-
503
- expect(process.exitCode).toBe(1);
504
- expect(fallback.callWithFallback).not.toHaveBeenCalled();
505
- });
506
-
507
- it('rejects a non-positive --max-concurrency before touching the network', async () => {
508
- mkdirSync(join(dir, 'scenarios'));
509
- writeFileSync(join(dir, 'scenarios', 's0.json'), '{}');
510
- const yamlPath = writeFanOutYaml(dir);
511
-
512
- await batchCommand(config, ['run', yamlPath, '--fan-out', join(dir, 'scenarios'), '--max-concurrency', '0'], chalk);
513
-
514
- expect(process.exitCode).toBe(1);
515
- expect(fallback.callWithFallback).not.toHaveBeenCalled();
516
- });
517
-
518
- it('never exceeds --max-concurrency in-flight submissions', async () => {
519
- mkdirSync(join(dir, 'scenarios'));
520
- for (let i = 0; i < 6; i++) writeFileSync(join(dir, 'scenarios', `s${i}.json`), '{}');
521
- const yamlPath = writeFanOutYaml(dir);
522
-
523
- let inFlight = 0;
524
- let maxInFlight = 0;
525
- let depCall = 0;
526
- fallback.callWithFallback.mockImplementation(async () => {
527
- depCall += 1;
528
- inFlight++;
529
- maxInFlight = Math.max(maxInFlight, inFlight);
530
- await new Promise(r => setTimeout(r, 5));
531
- return {
532
- deployment_id: `dep-${depCall}`, receipt_id: `rcpt-${depCall}`, gpu_type: 'RTX_4090',
533
- gpu_count: 1, cost_per_hour: 0.2, provider: 'runpod', tier: '1', status: 'running',
534
- };
535
- });
536
- const { monitorBatchJob } = await import('../src/batch.js');
537
- monitorBatchJob.mockImplementation(async () => {
538
- await new Promise(r => setTimeout(r, 5));
539
- inFlight--;
540
- return { status: 'succeeded', exitCode: 0, runtimeMs: 5, reason: 'complete' };
541
- });
542
- api.callApi.mockResolvedValue({ status: 'succeeded', teardown_ok: 'ok', runtime_seconds: 1, accrued_cost_usd: 0.01 });
543
- api.uploadBlob.mockResolvedValue({ code_uri: 'https://cdn.example.com/inputs.tar.gz', upload_id: 'up_1' });
544
-
545
- await batchCommand(config, ['run', yamlPath, '--fan-out', join(dir, 'scenarios'), '--max-concurrency', '2'], chalk);
546
-
547
- expect(maxInFlight).toBeLessThanOrEqual(2);
548
- expect(depCall).toBe(6);
549
- });
550
- });
551
-
552
- // ---------------------------------------------------------------------------
553
- // badgr batch artifacts — clear, actionable failure messages
554
- // ---------------------------------------------------------------------------
555
-
556
- describe('badgr batch artifacts — error clarity', () => {
557
- it('explains a 404 (no artifact ever uploaded) with likely causes and a next step', async () => {
558
- const err = new Error('GET /deployments/dep-nofiles/artifacts → HTTP 404: artifact_not_found');
559
- err.httpStatus = 404;
560
- api.callApi.mockImplementation((path) => {
561
- if (path.endsWith('/artifacts')) return Promise.reject(err);
562
- return Promise.resolve({});
563
- });
564
-
565
- await batchCommand(config, ['artifacts', 'dep-nofiles'], chalk);
566
-
567
- expect(process.exitCode).toBe(1);
568
- const logged = console.error.mock.calls.flat().join('\n');
569
- expect(logged).toMatch(/No artifact was ever uploaded for run dep-nofiles/);
570
- expect(logged).toMatch(/badgr batch logs dep-nofiles/);
571
- });
572
-
573
- it('explains a 410 (expired, 48h retention) plainly', async () => {
574
- const err = new Error('GET /deployments/dep-old/artifacts → HTTP 410: artifact_expired');
575
- err.httpStatus = 410;
576
- api.callApi.mockImplementation((path) => {
577
- if (path.endsWith('/artifacts')) return Promise.reject(err);
578
- return Promise.resolve({});
579
- });
580
-
581
- await batchCommand(config, ['artifacts', 'dep-old'], chalk);
582
-
583
- expect(process.exitCode).toBe(1);
584
- const logged = console.error.mock.calls.flat().join('\n');
585
- expect(logged).toMatch(/expired \(48h retention\)/);
586
- });
587
- });
588
-
589
- // ---------------------------------------------------------------------------
590
- // badgr batch compare — direct metrics.json files (test scenario 5)
591
- // ---------------------------------------------------------------------------
592
-
593
- describe('badgr batch compare', () => {
594
- it('reports a regression with delta when run B is worse (higher_is_better)', async () => {
595
- const a = join(dir, 'a.json');
596
- const b = join(dir, 'b.json');
597
- writeFileSync(a, JSON.stringify({ pass_rate: 0.92 }));
598
- writeFileSync(b, JSON.stringify({ pass_rate: 0.87 }));
599
-
600
- await batchCommand(config, ['compare', a, b], chalk);
601
-
602
- expect(process.exitCode).toBe(1);
603
- const logged = console.log.mock.calls.flat().join('\n');
604
- expect(logged).toMatch(/Regression/);
605
- expect(logged).toContain('0.92');
606
- expect(logged).toContain('0.87');
607
- });
608
-
609
- it('reports an improvement when run B is better', async () => {
610
- const a = join(dir, 'a.json');
611
- const b = join(dir, 'b.json');
612
- writeFileSync(a, JSON.stringify({ pass_rate: 0.80 }));
613
- writeFileSync(b, JSON.stringify({ pass_rate: 0.95 }));
614
-
615
- await batchCommand(config, ['compare', a, b], chalk);
616
-
617
- expect(process.exitCode).toBeUndefined();
618
- const logged = console.log.mock.calls.flat().join('\n');
619
- expect(logged).toMatch(/Improvement/);
620
- });
621
-
622
- it('respects --key and --higher-is-better overrides for raw files', async () => {
623
- const a = join(dir, 'a.json');
624
- const b = join(dir, 'b.json');
625
- writeFileSync(a, JSON.stringify({ error_rate: 0.05 }));
626
- writeFileSync(b, JSON.stringify({ error_rate: 0.10 }));
627
-
628
- // error_rate: lower is better, so b (higher) is a regression.
629
- await batchCommand(config, ['compare', a, b, '--key', 'error_rate', '--higher-is-better', 'false'], chalk);
630
-
631
- expect(process.exitCode).toBe(1);
632
- const logged = console.log.mock.calls.flat().join('\n');
633
- expect(logged).toMatch(/Regression/);
634
- });
635
-
636
- it('errors out when a run_id has no known success_metric and no local YAML', async () => {
637
- store.loadStore.mockReturnValueOnce({ receipts: [], deployments: [] });
638
- await batchCommand(config, ['compare', 'dep-unknown-a', 'dep-unknown-b'], chalk);
639
- expect(process.exitCode).toBe(1);
640
- });
641
- });