badgr-cli 1.1.0 → 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 +135 -3
- 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
|
@@ -1,176 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* badgr train lora — dataset validation, upload dedup, and --resume.
|
|
3
|
-
*
|
|
4
|
-
* A malformed local .jsonl dataset must be rejected before it's uploaded or
|
|
5
|
-
* a GPU is provisioned. A dataset whose content hasn't changed since a prior
|
|
6
|
-
* run must not be re-uploaded (upload cache keyed by content hash). --resume
|
|
7
|
-
* must thread resume_from_checkpoint_url into the job input.
|
|
8
|
-
*/
|
|
9
|
-
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
10
|
-
import { trainLoraCommand, parseTrainLoraArgs } from '../src/commands/train.js';
|
|
11
|
-
|
|
12
|
-
vi.mock('../src/api.js', async (importOriginal) => {
|
|
13
|
-
const actual = await importOriginal();
|
|
14
|
-
return { ...actual, callApi: vi.fn(), uploadBlob: actual.uploadBlob };
|
|
15
|
-
});
|
|
16
|
-
|
|
17
|
-
vi.mock('../src/store.js', () => ({
|
|
18
|
-
addReceipt: vi.fn(),
|
|
19
|
-
generateReceiptId: vi.fn(() => 'rcpt-ds-001'),
|
|
20
|
-
getCachedUploadId: vi.fn(() => null),
|
|
21
|
-
setCachedUploadId: vi.fn(),
|
|
22
|
-
}));
|
|
23
|
-
|
|
24
|
-
vi.mock('fs', async (importOriginal) => {
|
|
25
|
-
const actual = await importOriginal();
|
|
26
|
-
return {
|
|
27
|
-
...actual,
|
|
28
|
-
readFileSync: vi.fn(),
|
|
29
|
-
existsSync: vi.fn(() => true),
|
|
30
|
-
};
|
|
31
|
-
});
|
|
32
|
-
|
|
33
|
-
import * as api from '../src/api.js';
|
|
34
|
-
import * as store from '../src/store.js';
|
|
35
|
-
import * as fs from 'fs';
|
|
36
|
-
|
|
37
|
-
const chalk = {
|
|
38
|
-
bold: s => s, dim: s => s, red: s => s, yellow: s => s, green: s => s, cyan: s => s,
|
|
39
|
-
};
|
|
40
|
-
|
|
41
|
-
const config = { apiKey: 'sk-test', baseUrl: 'https://api.test/v1' };
|
|
42
|
-
|
|
43
|
-
beforeEach(() => {
|
|
44
|
-
process.exitCode = undefined;
|
|
45
|
-
vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
46
|
-
vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
47
|
-
vi.spyOn(process.stdout, 'write').mockImplementation(() => true);
|
|
48
|
-
vi.clearAllMocks();
|
|
49
|
-
fs.existsSync.mockReturnValue(true);
|
|
50
|
-
store.getCachedUploadId.mockReturnValue(null);
|
|
51
|
-
// POST /jobs returns the created job; the subsequent GET /jobs/{id} poll
|
|
52
|
-
// (pollJobUntilTerminal, 15s real-world interval) must resolve terminal
|
|
53
|
-
// immediately so these tests don't wait on real timers.
|
|
54
|
-
api.callApi.mockImplementation((path) => {
|
|
55
|
-
if (path === '/jobs') return Promise.resolve({ job_id: 'job-1', status: 'queued' });
|
|
56
|
-
return Promise.resolve({ job_id: 'job-1', status: 'completed', output: {} });
|
|
57
|
-
});
|
|
58
|
-
vi.useFakeTimers();
|
|
59
|
-
});
|
|
60
|
-
|
|
61
|
-
afterEach(() => {
|
|
62
|
-
vi.useRealTimers();
|
|
63
|
-
vi.restoreAllMocks();
|
|
64
|
-
process.exitCode = undefined;
|
|
65
|
-
});
|
|
66
|
-
|
|
67
|
-
async function runTrainLora(args) {
|
|
68
|
-
const promise = trainLoraCommand(config, args, chalk);
|
|
69
|
-
await vi.advanceTimersByTimeAsync(20_000);
|
|
70
|
-
return promise;
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
describe('parseTrainLoraArgs --resume', () => {
|
|
74
|
-
it('parses --resume', () => {
|
|
75
|
-
const flags = parseTrainLoraArgs(['--base-model', 'x', '--resume', 'https://cdn/ckpt.tar.gz']);
|
|
76
|
-
expect(flags.resume).toBe('https://cdn/ckpt.tar.gz');
|
|
77
|
-
});
|
|
78
|
-
});
|
|
79
|
-
|
|
80
|
-
describe('trainLoraCommand dataset validation', () => {
|
|
81
|
-
it('rejects a .jsonl dataset with an invalid line before uploading', async () => {
|
|
82
|
-
fs.readFileSync.mockReturnValue(Buffer.from('{"a":1}\nnot json\n{"b":2}\n'));
|
|
83
|
-
const fetchSpy = vi.spyOn(globalThis, 'fetch');
|
|
84
|
-
|
|
85
|
-
await trainLoraCommand(config, [
|
|
86
|
-
'--base-model', 'mistralai/Mistral-7B-v0.1',
|
|
87
|
-
'--dataset', './bad.jsonl',
|
|
88
|
-
'--max-cost', '10',
|
|
89
|
-
], chalk);
|
|
90
|
-
|
|
91
|
-
expect(process.exitCode).toBe(1);
|
|
92
|
-
expect(fetchSpy).not.toHaveBeenCalled();
|
|
93
|
-
const logged = console.error.mock.calls.flat().join('\n');
|
|
94
|
-
expect(logged).toContain('not valid JSONL');
|
|
95
|
-
});
|
|
96
|
-
|
|
97
|
-
it('rejects an empty .jsonl dataset', async () => {
|
|
98
|
-
fs.readFileSync.mockReturnValue(Buffer.from(' \n \n'));
|
|
99
|
-
|
|
100
|
-
await trainLoraCommand(config, [
|
|
101
|
-
'--base-model', 'mistralai/Mistral-7B-v0.1',
|
|
102
|
-
'--dataset', './empty.jsonl',
|
|
103
|
-
'--max-cost', '10',
|
|
104
|
-
], chalk);
|
|
105
|
-
|
|
106
|
-
expect(process.exitCode).toBe(1);
|
|
107
|
-
const logged = console.error.mock.calls.flat().join('\n');
|
|
108
|
-
expect(logged).toContain('empty');
|
|
109
|
-
});
|
|
110
|
-
|
|
111
|
-
it('uploads a valid .jsonl dataset and caches the resulting upload id', async () => {
|
|
112
|
-
fs.readFileSync.mockReturnValue(Buffer.from('{"a":1}\n{"b":2}\n'));
|
|
113
|
-
const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue({
|
|
114
|
-
ok: true, json: async () => ({ upload_id: 'up_new_1' }),
|
|
115
|
-
});
|
|
116
|
-
|
|
117
|
-
await runTrainLora([
|
|
118
|
-
'--base-model', 'mistralai/Mistral-7B-v0.1',
|
|
119
|
-
'--dataset', './good.jsonl',
|
|
120
|
-
'--max-cost', '10',
|
|
121
|
-
]);
|
|
122
|
-
|
|
123
|
-
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
|
124
|
-
expect(store.setCachedUploadId).toHaveBeenCalledWith(expect.any(String), 'up_new_1', expect.any(Object));
|
|
125
|
-
expect(api.callApi).toHaveBeenCalledWith('/jobs', expect.objectContaining({
|
|
126
|
-
body: expect.objectContaining({ input: expect.objectContaining({ dataset_file_id: 'up_new_1' }) }),
|
|
127
|
-
}));
|
|
128
|
-
});
|
|
129
|
-
|
|
130
|
-
it('skips re-upload when the dataset content matches a cached hash', async () => {
|
|
131
|
-
fs.readFileSync.mockReturnValue(Buffer.from('{"a":1}\n{"b":2}\n'));
|
|
132
|
-
store.getCachedUploadId.mockReturnValue('up_cached_1');
|
|
133
|
-
const fetchSpy = vi.spyOn(globalThis, 'fetch');
|
|
134
|
-
|
|
135
|
-
await runTrainLora([
|
|
136
|
-
'--base-model', 'mistralai/Mistral-7B-v0.1',
|
|
137
|
-
'--dataset', './good.jsonl',
|
|
138
|
-
'--max-cost', '10',
|
|
139
|
-
]);
|
|
140
|
-
|
|
141
|
-
expect(fetchSpy).not.toHaveBeenCalled();
|
|
142
|
-
expect(api.callApi).toHaveBeenCalledWith('/jobs', expect.objectContaining({
|
|
143
|
-
body: expect.objectContaining({ input: expect.objectContaining({ dataset_file_id: 'up_cached_1' }) }),
|
|
144
|
-
}));
|
|
145
|
-
const logged = console.log.mock.calls.flat().join('\n');
|
|
146
|
-
expect(logged).toContain('reusing upload');
|
|
147
|
-
});
|
|
148
|
-
});
|
|
149
|
-
|
|
150
|
-
describe('trainLoraCommand --resume', () => {
|
|
151
|
-
it('threads resume_from_checkpoint_url into the job input', async () => {
|
|
152
|
-
await runTrainLora([
|
|
153
|
-
'--base-model', 'mistralai/Mistral-7B-v0.1',
|
|
154
|
-
'--dataset', 'https://example.com/data.jsonl',
|
|
155
|
-
'--max-cost', '10',
|
|
156
|
-
'--resume', 'https://cdn/ckpt.tar.gz',
|
|
157
|
-
]);
|
|
158
|
-
|
|
159
|
-
expect(api.callApi).toHaveBeenCalledWith('/jobs', expect.objectContaining({
|
|
160
|
-
body: expect.objectContaining({
|
|
161
|
-
input: expect.objectContaining({ resume_from_checkpoint_url: 'https://cdn/ckpt.tar.gz' }),
|
|
162
|
-
}),
|
|
163
|
-
}));
|
|
164
|
-
});
|
|
165
|
-
|
|
166
|
-
it('omits resume_from_checkpoint_url when --resume is not given', async () => {
|
|
167
|
-
await runTrainLora([
|
|
168
|
-
'--base-model', 'mistralai/Mistral-7B-v0.1',
|
|
169
|
-
'--dataset', 'https://example.com/data.jsonl',
|
|
170
|
-
'--max-cost', '10',
|
|
171
|
-
]);
|
|
172
|
-
|
|
173
|
-
const call = api.callApi.mock.calls.find(c => c[0] === '/jobs');
|
|
174
|
-
expect(call[1].body.input.resume_from_checkpoint_url).toBeUndefined();
|
|
175
|
-
});
|
|
176
|
-
});
|
package/tests/upload.test.js
DELETED
|
@@ -1,79 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* badgr run — local-project upload flow (regression).
|
|
3
|
-
*
|
|
4
|
-
* Guards the bug where `_uploadCodeZip` imported `node-fetch` (an undeclared
|
|
5
|
-
* dependency) and the `form-data` package: both broke `badgr run . --cmd …`
|
|
6
|
-
* with "Cannot find package 'form-data'". The upload now uses Node's built-in
|
|
7
|
-
* FormData / Blob / fetch (Node >=18), so the CLI needs no extra HTTP deps.
|
|
8
|
-
*/
|
|
9
|
-
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
10
|
-
import fs from 'fs';
|
|
11
|
-
import os from 'os';
|
|
12
|
-
import path from 'path';
|
|
13
|
-
import { _uploadCodeZip } from '../src/commands/run.js';
|
|
14
|
-
|
|
15
|
-
const chalk = { dim: (s) => s, bold: (s) => s, red: (s) => s };
|
|
16
|
-
|
|
17
|
-
let tmpDir;
|
|
18
|
-
|
|
19
|
-
beforeEach(() => {
|
|
20
|
-
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'badgr-upload-test-'));
|
|
21
|
-
fs.writeFileSync(path.join(tmpDir, 'hello.py'), "print('hi')\n");
|
|
22
|
-
vi.spyOn(process.stdout, 'write').mockImplementation(() => true);
|
|
23
|
-
});
|
|
24
|
-
|
|
25
|
-
afterEach(() => {
|
|
26
|
-
vi.restoreAllMocks();
|
|
27
|
-
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
28
|
-
});
|
|
29
|
-
|
|
30
|
-
describe('_uploadCodeZip (local-project flow)', () => {
|
|
31
|
-
it('uses built-in fetch + FormData and returns the backend code_uri', async () => {
|
|
32
|
-
// Built-ins must exist in the supported Node runtime — if these are
|
|
33
|
-
// undefined the upload would fall back to the missing node-fetch/form-data.
|
|
34
|
-
expect(typeof fetch).toBe('function');
|
|
35
|
-
expect(typeof FormData).toBe('function');
|
|
36
|
-
expect(typeof Blob).toBe('function');
|
|
37
|
-
|
|
38
|
-
let captured = null;
|
|
39
|
-
const fetchSpy = vi.spyOn(globalThis, 'fetch').mockImplementation(async (url, opts) => {
|
|
40
|
-
captured = { url, opts };
|
|
41
|
-
return { ok: true, json: async () => ({ code_uri: 'https://aibadgr.com/v1/uploads/up_x/download?token=t' }) };
|
|
42
|
-
});
|
|
43
|
-
|
|
44
|
-
const config = { apiKey: 'sk-test', baseUrl: 'https://aibadgr.com/v1' };
|
|
45
|
-
const codeUri = await _uploadCodeZip(config, tmpDir, chalk);
|
|
46
|
-
|
|
47
|
-
expect(codeUri).toBe('https://aibadgr.com/v1/uploads/up_x/download?token=t');
|
|
48
|
-
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
|
49
|
-
expect(captured.url).toBe('https://aibadgr.com/v1/uploads');
|
|
50
|
-
expect(captured.opts.method).toBe('POST');
|
|
51
|
-
expect(captured.opts.body).toBeInstanceOf(FormData);
|
|
52
|
-
expect(captured.opts.body.get('file')).toBeInstanceOf(Blob);
|
|
53
|
-
expect(captured.opts.headers.Authorization).toBe('Bearer sk-test');
|
|
54
|
-
// fetch derives the multipart Content-Type/boundary from the FormData body —
|
|
55
|
-
// the CLI must NOT set it manually (that was the form-data getHeaders() path).
|
|
56
|
-
expect(captured.opts.headers['Content-Type']).toBeUndefined();
|
|
57
|
-
});
|
|
58
|
-
|
|
59
|
-
it('throws a clear error and still deletes the temp zip when upload fails', async () => {
|
|
60
|
-
const listZips = () =>
|
|
61
|
-
new Set(fs.readdirSync(os.tmpdir()).filter(f => f.startsWith('badgr-upload-') && f.endsWith('.zip')));
|
|
62
|
-
const before = listZips();
|
|
63
|
-
|
|
64
|
-
vi.spyOn(globalThis, 'fetch').mockResolvedValue({
|
|
65
|
-
ok: false,
|
|
66
|
-
status: 500,
|
|
67
|
-
statusText: 'Internal Server Error',
|
|
68
|
-
text: async () => 'boom',
|
|
69
|
-
});
|
|
70
|
-
|
|
71
|
-
const config = { apiKey: 'sk-test', baseUrl: 'https://aibadgr.com/v1' };
|
|
72
|
-
await expect(_uploadCodeZip(config, tmpDir, chalk)).rejects.toThrow(/Upload failed: 500/);
|
|
73
|
-
|
|
74
|
-
// The temp zip created by THIS call must be cleaned up even on failure.
|
|
75
|
-
const after = listZips();
|
|
76
|
-
const newLeftovers = [...after].filter(f => !before.has(f));
|
|
77
|
-
expect(newLeftovers).toEqual([]);
|
|
78
|
-
});
|
|
79
|
-
});
|
|
@@ -1,56 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
-
|
|
3
|
-
// Verify `badgr workload run` (rerun a saved workflow) resolves name→id and
|
|
4
|
-
// merges --set / --max-cost / --max-runtime into the POST body the server
|
|
5
|
-
// expects, without provisioning a GPU.
|
|
6
|
-
const calls = [];
|
|
7
|
-
vi.mock('../src/api.js', () => ({
|
|
8
|
-
callApi: vi.fn(async (path, opts = {}) => {
|
|
9
|
-
calls.push({ path, opts });
|
|
10
|
-
if (path.startsWith('/workloads?')) {
|
|
11
|
-
return { workloads: [{ name: 'mini-train', workload_id: 'wl_abc' }], total: 1 };
|
|
12
|
-
}
|
|
13
|
-
if (path === '/workloads/wl_abc/run') {
|
|
14
|
-
return { job_id: 'job_1', status_url: 'https://aibadgr.com/v1/jobs/job_1', estimated_cost_usd: 1.23 };
|
|
15
|
-
}
|
|
16
|
-
return {};
|
|
17
|
-
}),
|
|
18
|
-
}));
|
|
19
|
-
|
|
20
|
-
const { workloadCommand } = await import('../src/commands/workload.js');
|
|
21
|
-
const chalk = new Proxy({}, { get: () => (s) => s });
|
|
22
|
-
const config = { apiKey: 'k', baseUrl: 'https://aibadgr.com/v1' };
|
|
23
|
-
|
|
24
|
-
beforeEach(() => {
|
|
25
|
-
calls.length = 0;
|
|
26
|
-
vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
27
|
-
});
|
|
28
|
-
|
|
29
|
-
describe('badgr workload run (rerun saved workflow)', () => {
|
|
30
|
-
it('resolves name→id and merges --set / --max-cost / --max-runtime into the rerun body', async () => {
|
|
31
|
-
await workloadCommand(
|
|
32
|
-
config,
|
|
33
|
-
['run', 'mini-train', '--set', 'gpu=H100', '--max-cost', '8', '--max-runtime', '30'],
|
|
34
|
-
chalk,
|
|
35
|
-
);
|
|
36
|
-
|
|
37
|
-
const resolve = calls.find(c => c.path.startsWith('/workloads?'));
|
|
38
|
-
expect(resolve, 'should look up workload by name').toBeTruthy();
|
|
39
|
-
expect(resolve.path.startsWith('/v1/')).toBe(false);
|
|
40
|
-
|
|
41
|
-
const run = calls.find(c => c.path === '/workloads/wl_abc/run');
|
|
42
|
-
expect(run, 'should POST to the rerun endpoint').toBeTruthy();
|
|
43
|
-
expect(run.opts.method).toBe('POST');
|
|
44
|
-
expect(run.opts.body).toEqual({
|
|
45
|
-
config_overrides: { gpu: 'H100' },
|
|
46
|
-
max_cost: 8,
|
|
47
|
-
max_runtime_minutes: 30,
|
|
48
|
-
});
|
|
49
|
-
});
|
|
50
|
-
|
|
51
|
-
it('sends only config_overrides when no caps are passed', async () => {
|
|
52
|
-
await workloadCommand(config, ['run', 'mini-train', '--set', 'steps=50'], chalk);
|
|
53
|
-
const run = calls.find(c => c.path === '/workloads/wl_abc/run');
|
|
54
|
-
expect(run.opts.body).toEqual({ config_overrides: { steps: '50' } });
|
|
55
|
-
});
|
|
56
|
-
});
|
|
@@ -1,180 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
2
|
-
import { mkdtempSync, writeFileSync, rmSync } from 'fs';
|
|
3
|
-
import { tmpdir } from 'os';
|
|
4
|
-
import { join } from 'path';
|
|
5
|
-
import {
|
|
6
|
-
parseWorkloadYaml,
|
|
7
|
-
parseInputEntry,
|
|
8
|
-
validateWorkloadSpec,
|
|
9
|
-
WorkloadSpecError,
|
|
10
|
-
} from '../src/workloadSpec.js';
|
|
11
|
-
|
|
12
|
-
let dir;
|
|
13
|
-
|
|
14
|
-
beforeEach(() => {
|
|
15
|
-
dir = mkdtempSync(join(tmpdir(), 'badgr-batch-test-'));
|
|
16
|
-
});
|
|
17
|
-
|
|
18
|
-
afterEach(() => {
|
|
19
|
-
rmSync(dir, { recursive: true, force: true });
|
|
20
|
-
});
|
|
21
|
-
|
|
22
|
-
function writeYaml(name, content) {
|
|
23
|
-
const path = join(dir, name);
|
|
24
|
-
writeFileSync(path, content);
|
|
25
|
-
return path;
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
describe('parseInputEntry', () => {
|
|
29
|
-
it('splits local:container on the last colon', () => {
|
|
30
|
-
const { localPath, containerPath } = parseInputEntry('./policy.py:/inputs/policy.py');
|
|
31
|
-
expect(localPath).toBe('./policy.py');
|
|
32
|
-
expect(containerPath).toBe('/inputs/policy.py');
|
|
33
|
-
});
|
|
34
|
-
|
|
35
|
-
it('rejects a non-absolute container path', () => {
|
|
36
|
-
expect(() => parseInputEntry('./policy.py:inputs/policy.py')).toThrow(WorkloadSpecError);
|
|
37
|
-
});
|
|
38
|
-
|
|
39
|
-
it('rejects an entry with no colon', () => {
|
|
40
|
-
expect(() => parseInputEntry('./policy.py')).toThrow(WorkloadSpecError);
|
|
41
|
-
});
|
|
42
|
-
});
|
|
43
|
-
|
|
44
|
-
describe('validateWorkloadSpec', () => {
|
|
45
|
-
it('requires name, image, command, max_cost, max_runtime_minutes', () => {
|
|
46
|
-
const errors = validateWorkloadSpec({});
|
|
47
|
-
expect(errors).toContain('name is required');
|
|
48
|
-
expect(errors).toContain('image is required');
|
|
49
|
-
expect(errors).toContain('command is required and must be a non-empty list');
|
|
50
|
-
expect(errors).toContain('max_cost is required and must be a positive number');
|
|
51
|
-
expect(errors).toContain('max_runtime_minutes is required and must be a positive number');
|
|
52
|
-
});
|
|
53
|
-
|
|
54
|
-
it('passes on a minimal valid spec', () => {
|
|
55
|
-
const errors = validateWorkloadSpec({
|
|
56
|
-
name: 'toy', image: 'busybox', command: ['echo', 'hi'],
|
|
57
|
-
max_cost: 5, max_runtime_minutes: 10,
|
|
58
|
-
});
|
|
59
|
-
expect(errors).toEqual([]);
|
|
60
|
-
});
|
|
61
|
-
|
|
62
|
-
it('flags a malformed inputs entry', () => {
|
|
63
|
-
const errors = validateWorkloadSpec({
|
|
64
|
-
name: 'toy', image: 'busybox', command: ['echo'],
|
|
65
|
-
max_cost: 5, max_runtime_minutes: 10,
|
|
66
|
-
inputs: ['./policy.py'],
|
|
67
|
-
});
|
|
68
|
-
expect(errors.some(e => e.includes('inputs entry'))).toBe(true);
|
|
69
|
-
});
|
|
70
|
-
|
|
71
|
-
it('flags outputs that are not absolute paths', () => {
|
|
72
|
-
const errors = validateWorkloadSpec({
|
|
73
|
-
name: 'toy', image: 'busybox', command: ['echo'],
|
|
74
|
-
max_cost: 5, max_runtime_minutes: 10,
|
|
75
|
-
outputs: ['outputs/metrics.json'],
|
|
76
|
-
});
|
|
77
|
-
expect(errors).toContain('outputs must be a list of absolute container paths');
|
|
78
|
-
});
|
|
79
|
-
|
|
80
|
-
it('requires success_metric.file and key when success_metric is set', () => {
|
|
81
|
-
const errors = validateWorkloadSpec({
|
|
82
|
-
name: 'toy', image: 'busybox', command: ['echo'],
|
|
83
|
-
max_cost: 5, max_runtime_minutes: 10,
|
|
84
|
-
success_metric: {},
|
|
85
|
-
});
|
|
86
|
-
expect(errors.some(e => e.includes('success_metric.file'))).toBe(true);
|
|
87
|
-
expect(errors.some(e => e.includes('success_metric.key'))).toBe(true);
|
|
88
|
-
});
|
|
89
|
-
});
|
|
90
|
-
|
|
91
|
-
describe('parseWorkloadYaml', () => {
|
|
92
|
-
it('parses the minimal toy-robot-eval example from the spec', () => {
|
|
93
|
-
const path = writeYaml('workload.yml', `
|
|
94
|
-
name: toy-robot-eval
|
|
95
|
-
image: badgr/physical-ai-eval-lite:latest
|
|
96
|
-
|
|
97
|
-
command:
|
|
98
|
-
- python
|
|
99
|
-
- run_eval.py
|
|
100
|
-
- --policy
|
|
101
|
-
- /inputs/policy.py
|
|
102
|
-
- --scenarios
|
|
103
|
-
- /inputs/scenarios
|
|
104
|
-
|
|
105
|
-
inputs:
|
|
106
|
-
- ./policy.py:/inputs/policy.py
|
|
107
|
-
- ./scenarios:/inputs/scenarios
|
|
108
|
-
|
|
109
|
-
outputs:
|
|
110
|
-
- /outputs/metrics.json
|
|
111
|
-
- /outputs/videos
|
|
112
|
-
- /outputs/logs
|
|
113
|
-
|
|
114
|
-
max_cost: 20
|
|
115
|
-
max_runtime_minutes: 60
|
|
116
|
-
|
|
117
|
-
success_metric:
|
|
118
|
-
file: /outputs/metrics.json
|
|
119
|
-
key: pass_rate
|
|
120
|
-
higher_is_better: true
|
|
121
|
-
`);
|
|
122
|
-
const spec = parseWorkloadYaml(path);
|
|
123
|
-
expect(spec.name).toBe('toy-robot-eval');
|
|
124
|
-
expect(spec.image).toBe('badgr/physical-ai-eval-lite:latest');
|
|
125
|
-
expect(spec.command).toEqual(['python', 'run_eval.py', '--policy', '/inputs/policy.py', '--scenarios', '/inputs/scenarios']);
|
|
126
|
-
expect(spec.inputs).toHaveLength(2);
|
|
127
|
-
expect(spec.inputs[0].containerPath).toBe('/inputs/policy.py');
|
|
128
|
-
expect(spec.inputs[0].localPath).toBe(join(dir, 'policy.py'));
|
|
129
|
-
expect(spec.outputs).toEqual(['/outputs/metrics.json', '/outputs/videos', '/outputs/logs']);
|
|
130
|
-
expect(spec.maxCost).toBe(20);
|
|
131
|
-
expect(spec.maxRuntimeMinutes).toBe(60);
|
|
132
|
-
expect(spec.successMetric).toEqual({ file: '/outputs/metrics.json', key: 'pass_rate', higherIsBetter: true });
|
|
133
|
-
});
|
|
134
|
-
|
|
135
|
-
it('defaults higherIsBetter to true when omitted', () => {
|
|
136
|
-
const path = writeYaml('workload.yml', `
|
|
137
|
-
name: toy
|
|
138
|
-
image: busybox
|
|
139
|
-
command: [echo, hi]
|
|
140
|
-
max_cost: 1
|
|
141
|
-
max_runtime_minutes: 1
|
|
142
|
-
success_metric:
|
|
143
|
-
file: /outputs/metrics.json
|
|
144
|
-
key: score
|
|
145
|
-
`);
|
|
146
|
-
const spec = parseWorkloadYaml(path);
|
|
147
|
-
expect(spec.successMetric.higherIsBetter).toBe(true);
|
|
148
|
-
});
|
|
149
|
-
|
|
150
|
-
it('defaults inputs/outputs/env to empty', () => {
|
|
151
|
-
const path = writeYaml('workload.yml', `
|
|
152
|
-
name: toy
|
|
153
|
-
image: busybox
|
|
154
|
-
command: [echo, hi]
|
|
155
|
-
max_cost: 1
|
|
156
|
-
max_runtime_minutes: 1
|
|
157
|
-
`);
|
|
158
|
-
const spec = parseWorkloadYaml(path);
|
|
159
|
-
expect(spec.inputs).toEqual([]);
|
|
160
|
-
expect(spec.outputs).toEqual([]);
|
|
161
|
-
expect(spec.env).toEqual({});
|
|
162
|
-
expect(spec.successMetric).toBeNull();
|
|
163
|
-
});
|
|
164
|
-
|
|
165
|
-
it('throws WorkloadSpecError with all problems on an invalid file', () => {
|
|
166
|
-
const path = writeYaml('workload.yml', `
|
|
167
|
-
image: busybox
|
|
168
|
-
`);
|
|
169
|
-
expect(() => parseWorkloadYaml(path)).toThrow(WorkloadSpecError);
|
|
170
|
-
});
|
|
171
|
-
|
|
172
|
-
it('throws WorkloadSpecError on malformed YAML', () => {
|
|
173
|
-
const path = writeYaml('workload.yml', '{ not: valid: yaml: [');
|
|
174
|
-
expect(() => parseWorkloadYaml(path)).toThrow(WorkloadSpecError);
|
|
175
|
-
});
|
|
176
|
-
|
|
177
|
-
it('throws WorkloadSpecError when the file does not exist', () => {
|
|
178
|
-
expect(() => parseWorkloadYaml(join(dir, 'nope.yml'))).toThrow(WorkloadSpecError);
|
|
179
|
-
});
|
|
180
|
-
});
|