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.
- package/LICENSE +207 -0
- package/README.md +9 -2
- package/package.json +44 -2
- package/src/api.js +16 -0
- package/src/badgr.js +2 -2
- package/src/commands/batch.js +11 -0
- package/src/commands/comfyui.js +31 -15
- package/src/commands/embed.js +13 -10
- package/src/commands/launch.js +8 -1
- package/src/commands/login.js +75 -20
- package/src/commands/run.js +45 -13
- package/src/commands/sbatch.js +6 -1
- package/src/commands/serve.js +44 -30
- package/src/commands/train.js +8 -12
- package/src/commands/transcribe.js +13 -10
- package/src/envFlag.js +10 -0
- package/src/onboarding.js +8 -1
- package/src/progress.js +48 -0
- package/tests/agent-images.test.js +0 -17
- package/tests/api.test.js +0 -168
- package/tests/artifactDownload.test.js +0 -113
- package/tests/artifacts.test.js +0 -168
- package/tests/batch.test.js +0 -641
- package/tests/browser.test.js +0 -51
- package/tests/capacity.test.js +0 -68
- package/tests/commands.test.js +0 -417
- package/tests/config.test.js +0 -96
- package/tests/connect.test.js +0 -83
- package/tests/detect.test.js +0 -191
- package/tests/down.test.js +0 -150
- package/tests/errors.test.js +0 -130
- package/tests/fallback-timeout.test.js +0 -41
- package/tests/fanout.test.js +0 -124
- package/tests/gpu-doctor-classifiers.test.js +0 -402
- package/tests/gpu-doctor-doctor.test.js +0 -304
- package/tests/gpu-doctor-probe-cache.test.js +0 -110
- package/tests/gpu-doctor-probes.test.js +0 -257
- package/tests/heartbeat.test.js +0 -70
- package/tests/job-progress-poll.test.js +0 -136
- package/tests/launch-command-argv.test.js +0 -93
- package/tests/launch-readiness.test.js +0 -403
- package/tests/launch.test.js +0 -440
- package/tests/onboarding.test.js +0 -134
- package/tests/productized-dry-run.test.js +0 -141
- package/tests/productized-runners.test.js +0 -237
- package/tests/pull.test.js +0 -266
- package/tests/rerun.test.js +0 -94
- package/tests/restart.test.js +0 -88
- package/tests/router.test.js +0 -98
- package/tests/run-lifecycle.test.js +0 -1054
- package/tests/sbatch.test.js +0 -190
- package/tests/secrets.test.js +0 -16
- package/tests/serve-apps.test.js +0 -189
- package/tests/serve-lifecycle.test.js +0 -931
- package/tests/slurm.test.js +0 -77
- package/tests/spec.test.js +0 -201
- package/tests/status.test.js +0 -73
- package/tests/store.test.js +0 -187
- package/tests/task.test.js +0 -109
- package/tests/template.test.js +0 -556
- package/tests/train-lora-dataset.test.js +0 -176
- package/tests/upload.test.js +0 -79
- package/tests/workload-rerun.test.js +0 -56
- package/tests/workload-spec.test.js +0 -180
- package/tests/workload-templates.test.js +0 -865
- package/tests/workload-workspace-paths.test.js +0 -46
package/tests/batch.test.js
DELETED
|
@@ -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
|
-
});
|