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.
Files changed (66) hide show
  1. package/LICENSE +207 -0
  2. package/README.md +135 -3
  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,77 +0,0 @@
1
- import { describe, it, expect } from 'vitest';
2
- import { parseSlurmScript, envForArrayTask, SlurmParseError } from '../src/slurm.js';
3
-
4
- const BASIC = `#!/bin/bash
5
- #SBATCH --job-name=train
6
- #SBATCH --cpus-per-task=32
7
- #SBATCH --mem=128G
8
- #SBATCH --gres=gpu:a100:2
9
- #SBATCH --time=02:30:00
10
- #SBATCH --export=FOO=bar,BAZ=qux
11
-
12
- python train.py --epochs 10
13
- `;
14
-
15
- const ARRAY = `#!/bin/bash
16
- #SBATCH --job-name=screen
17
- #SBATCH --array=1-100:5
18
- #SBATCH --gpus=1
19
- #SBATCH --partition=gpu-fast
20
- #SBATCH --mail-user=someone@example.com
21
-
22
- python screen.py --scenario $SLURM_ARRAY_TASK_ID
23
- `;
24
-
25
- describe('parseSlurmScript', () => {
26
- it('parses cpus, mem, gres, time, export', () => {
27
- const job = parseSlurmScript(BASIC, { filename: 'train.slurm' });
28
- expect(job.name).toBe('train');
29
- expect(job.cpus).toBe(32);
30
- expect(job.memGb).toBeCloseTo(128);
31
- expect(job.gpuCount).toBe(2);
32
- expect(job.gpuType).toBe('a100');
33
- expect(job.timeMinutes).toBe(150);
34
- expect(job.env).toEqual({ FOO: 'bar', BAZ: 'qux' });
35
- expect(job.command).toContain('python train.py --epochs 10');
36
- expect(job.arrayIndices).toEqual([]);
37
- });
38
-
39
- it('parses --array ranges with a step and ignores unknown directives', () => {
40
- const job = parseSlurmScript(ARRAY, { filename: 'screen.slurm' });
41
- expect(job.arrayIndices).toEqual([1, 6, 11, 16, 21, 26, 31, 36, 41, 46, 51, 56, 61, 66, 71, 76, 81, 86, 91, 96]);
42
- expect(job.gpuCount).toBe(1);
43
- expect(job.ignoredDirectives).toContain('--partition=gpu-fast');
44
- expect(job.ignoredDirectives).toContain('--mail-user=someone@example.com');
45
- });
46
-
47
- it('parses mem-per-cpu combined with cpus-per-task', () => {
48
- const job = parseSlurmScript(`#SBATCH --cpus-per-task=4\n#SBATCH --mem-per-cpu=2G\necho hi\n`);
49
- expect(job.memGb).toBeCloseTo(8);
50
- });
51
-
52
- it('parses simple comma/range --array lists', () => {
53
- const job = parseSlurmScript(`#SBATCH --array=1,3,5-7\necho hi\n`);
54
- expect(job.arrayIndices).toEqual([1, 3, 5, 6, 7]);
55
- });
56
-
57
- it('throws when there is no runnable command', () => {
58
- expect(() => parseSlurmScript(`#!/bin/bash\n#SBATCH --job-name=empty\n`)).toThrow(SlurmParseError);
59
- });
60
-
61
- it('supports export lines in the script body in addition to --export', () => {
62
- const job = parseSlurmScript(`#SBATCH --job-name=x\nexport MODE="fast"\npython run.py\n`);
63
- expect(job.env.MODE).toBe('fast');
64
- });
65
- });
66
-
67
- describe('envForArrayTask', () => {
68
- it('injects SLURM_ARRAY_TASK_ID/JOB_ID on top of the base env', () => {
69
- const env = envForArrayTask({ FOO: 'bar' }, 7, 'rcpt-123');
70
- expect(env).toEqual({ FOO: 'bar', SLURM_ARRAY_TASK_ID: '7', SLURM_ARRAY_JOB_ID: 'rcpt-123' });
71
- });
72
-
73
- it('leaves env untouched for non-array jobs', () => {
74
- const env = envForArrayTask({ FOO: 'bar' }, null, null);
75
- expect(env).toEqual({ FOO: 'bar' });
76
- });
77
- });
@@ -1,201 +0,0 @@
1
- import { describe, it, expect } from 'vitest';
2
- import { parseSpec, validateSpec, specLines, parseFlags, normalizeGpuType, VM_CLASSES, LAUNCH_VM_SIZES, vmClassForWorkload, parseGbSize } from '../src/spec.js';
3
-
4
- describe('vmClassForWorkload — deterministic workload → VM class table', () => {
5
- it('maps playwright to browser', () => expect(vmClassForWorkload('playwright')).toBe('browser'));
6
- it('maps cline/claude/codex to small', () => {
7
- expect(vmClassForWorkload('cline')).toBe('small');
8
- expect(vmClassForWorkload('claude')).toBe('small');
9
- expect(vmClassForWorkload('codex')).toBe('small');
10
- });
11
- it('defaults an unknown/explicit-form workload (null) to small', () => {
12
- expect(vmClassForWorkload(null)).toBe('small');
13
- });
14
- it('every LAUNCH_VM_SIZES entry has a VM_CLASSES definition', () => {
15
- for (const size of LAUNCH_VM_SIZES) expect(VM_CLASSES[size]).toBeTruthy();
16
- });
17
- });
18
-
19
- describe('normalizeGpuType', () => {
20
- it('maps RTX-4090 to RTX_4090', () => expect(normalizeGpuType('RTX-4090')).toBe('RTX_4090'));
21
- it('maps rtx4090 to RTX_4090', () => expect(normalizeGpuType('rtx4090')).toBe('RTX_4090'));
22
- it('maps 4090 to RTX_4090', () => expect(normalizeGpuType('4090')).toBe('RTX_4090'));
23
- it('maps h100 to H100', () => expect(normalizeGpuType('h100')).toBe('H100'));
24
- it('maps a100 to A100', () => expect(normalizeGpuType('a100')).toBe('A100'));
25
- it('defaults null to RTX_4090', () => expect(normalizeGpuType(null)).toBe('RTX_4090'));
26
- it('uppercases unknown types', () => expect(normalizeGpuType('l40s')).toBe('L40S'));
27
- });
28
-
29
- describe('parseFlags', () => {
30
- it('parses --key value pairs', () => {
31
- const f = parseFlags(['--model', 'llama-3', '--gpu', 'RTX_4090']);
32
- expect(f.model).toBe('llama-3');
33
- expect(f.gpu).toBe('RTX_4090');
34
- });
35
-
36
- it('parses boolean flags', () => {
37
- const f = parseFlags(['--dry-run']);
38
- expect(f['dry-run']).toBe(true);
39
- });
40
-
41
- it('handles mixed flags and values', () => {
42
- const f = parseFlags(['--model', 'gpt2', '--dry-run', '--count', '2']);
43
- expect(f.model).toBe('gpt2');
44
- expect(f['dry-run']).toBe(true);
45
- expect(f.count).toBe('2');
46
- });
47
- });
48
-
49
- describe('parseSpec', () => {
50
- it('defaults to endpoint type with default model', () => {
51
- const spec = parseSpec([]);
52
- expect(spec.type).toBe('endpoint');
53
- expect(spec.model).toBeTruthy();
54
- expect(spec.count).toBe(1);
55
- expect(spec.region).toBe('US');
56
- });
57
-
58
- it('infers job type from --image', () => {
59
- const spec = parseSpec(['--image', 'vllm/vllm-openai:latest']);
60
- expect(spec.type).toBe('job');
61
- expect(spec.image).toBe('vllm/vllm-openai:latest');
62
- });
63
-
64
- it('respects explicit --type', () => {
65
- const spec = parseSpec(['--type', 'job', '--image', 'myimg:latest']);
66
- expect(spec.type).toBe('job');
67
- });
68
-
69
- it('parses all flags', () => {
70
- const spec = parseSpec([
71
- '--model', 'llama-3',
72
- '--gpu', 'H100',
73
- '--count', '2',
74
- '--region', 'EU',
75
- '--max-price', '3.00',
76
- '--name', 'my-deploy',
77
- ]);
78
- expect(spec.model).toBe('llama-3');
79
- expect(spec.gpu).toBe('H100');
80
- expect(spec.count).toBe(2);
81
- expect(spec.region).toBe('EU');
82
- expect(spec.maxPrice).toBe(3.00);
83
- expect(spec.name).toBe('my-deploy');
84
- });
85
-
86
- it('sets dryRun when --dry-run present', () => {
87
- const spec = parseSpec(['--dry-run']);
88
- expect(spec.dryRun).toBe(true);
89
- });
90
-
91
- it('normalizes GPU type', () => {
92
- const spec = parseSpec(['--gpu', 'rtx-4090']);
93
- expect(spec.gpu).toBe('RTX_4090');
94
- });
95
-
96
- it('--endpoint shorthand sets type to endpoint', () => {
97
- const spec = parseSpec(['--endpoint', '--model', 'X']);
98
- expect(spec.type).toBe('endpoint');
99
- });
100
-
101
- it('--job shorthand sets type to job', () => {
102
- const spec = parseSpec(['--job', '--image', 'myimg:latest']);
103
- expect(spec.type).toBe('job');
104
- });
105
-
106
- it('--type takes precedence over shorthands', () => {
107
- const spec = parseSpec(['--type', 'job', '--endpoint']);
108
- expect(spec.type).toBe('job');
109
- });
110
-
111
- it('parses L40S gpu', () => {
112
- const spec = parseSpec(['--endpoint', '--model', 'X', '--gpu', 'L40S']);
113
- expect(spec.gpu).toBe('L40S');
114
- });
115
- });
116
-
117
- describe('validateSpec', () => {
118
- it('returns no errors for valid endpoint spec', () => {
119
- const spec = parseSpec(['--model', 'llama-3', '--gpu', 'RTX_4090']);
120
- expect(validateSpec(spec)).toEqual([]);
121
- });
122
-
123
- it('returns error for invalid type', () => {
124
- const spec = { ...parseSpec([]), type: 'invalid' };
125
- const errors = validateSpec(spec);
126
- expect(errors.some(e => e.includes('type'))).toBe(true);
127
- });
128
-
129
- it('returns error for count out of range', () => {
130
- const spec = { ...parseSpec([]), count: 0 };
131
- expect(validateSpec(spec).some(e => e.includes('count'))).toBe(true);
132
- });
133
-
134
- it('returns error for invalid region', () => {
135
- const spec = { ...parseSpec([]), region: 'MARS' };
136
- expect(validateSpec(spec).some(e => e.includes('region'))).toBe(true);
137
- });
138
- });
139
-
140
- describe('specLines', () => {
141
- it('includes type and gpu', () => {
142
- const spec = parseSpec(['--model', 'llama-3', '--gpu', 'RTX_4090']);
143
- const lines = specLines(spec);
144
- expect(lines.some(l => l.includes('endpoint'))).toBe(true);
145
- expect(lines.some(l => l.includes('RTX_4090'))).toBe(true);
146
- });
147
-
148
- it('includes model when present', () => {
149
- const spec = parseSpec(['--model', 'llama-3']);
150
- expect(specLines(spec).some(l => l.includes('llama-3'))).toBe(true);
151
- });
152
-
153
- it('omits null fields', () => {
154
- const spec = { ...parseSpec([]), maxPrice: null, name: null };
155
- const lines = specLines(spec);
156
- expect(lines.every(l => l !== null)).toBe(true);
157
- });
158
- });
159
-
160
-
161
- describe('CPU compute sizing', () => {
162
- it('selects CPU compute and heuristic size for agent tasks', async () => {
163
- const { parseSpec } = await import('../src/spec.js');
164
- const spec = parseSpec(['--type', 'job', '--compute', 'cpu', '--agent', 'claude', '--task', 'run e2e tests']);
165
- expect(spec.compute).toBe('cpu');
166
- expect(spec.gpu).toBe('CPU');
167
- expect(spec.cpuSize).toBe('large');
168
- });
169
- });
170
-
171
- describe('parseGbSize', () => {
172
- it('parses a plain GB suffix', () => {
173
- expect(parseGbSize('64GB')).toBe(64);
174
- expect(parseGbSize('64G')).toBe(64);
175
- });
176
-
177
- it('parses MB/TB/KB and converts to GB', () => {
178
- expect(parseGbSize('65536MB')).toBeCloseTo(64);
179
- expect(parseGbSize('1TB')).toBe(1024);
180
- expect(parseGbSize('1048576KB')).toBeCloseTo(1);
181
- });
182
-
183
- it('is case-insensitive', () => {
184
- expect(parseGbSize('24gb')).toBe(24);
185
- });
186
-
187
- it('defaults a bare number to GB unless bareUnit overrides it', () => {
188
- expect(parseGbSize('24')).toBe(24);
189
- expect(parseGbSize('2048', { bareUnit: 'M' })).toBe(2);
190
- });
191
-
192
- it('accepts a number input directly', () => {
193
- expect(parseGbSize(32)).toBe(32);
194
- });
195
-
196
- it('returns null for unparseable input', () => {
197
- expect(parseGbSize('not-a-size')).toBeNull();
198
- expect(parseGbSize(null)).toBeNull();
199
- expect(parseGbSize(undefined)).toBeNull();
200
- });
201
- });
@@ -1,73 +0,0 @@
1
- /**
2
- * badgr status — must show every non-terminal deployment, not just
3
- * "running"/"provisioning". A live run found deployments stuck in
4
- * "starting" that used to be silently dropped, reporting "Nothing
5
- * running" while real billable resources kept running.
6
- */
7
- import { describe, it, expect, vi } from 'vitest';
8
-
9
- vi.mock('../src/api.js', () => ({
10
- listDeployments: vi.fn(),
11
- }));
12
-
13
- vi.mock('../src/store.js', () => ({
14
- listDeployments: vi.fn(() => []),
15
- }));
16
-
17
- import { statusCommand } from '../src/commands/status.js';
18
- import { listDeployments as apiDeployments } from '../src/api.js';
19
-
20
- const chalk = new Proxy({}, { get: () => (s) => s });
21
-
22
- function makeDep(overrides) {
23
- return {
24
- deployment_id: 'dep-001',
25
- name: 'dep-001',
26
- workload_type: 'job',
27
- gpu_type: 'A100',
28
- gpu_count: 1,
29
- cost_per_hour: 0.5,
30
- ...overrides,
31
- };
32
- }
33
-
34
- async function runStatus(deployments) {
35
- apiDeployments.mockResolvedValueOnce({ deployments });
36
- const logs = [];
37
- const spy = vi.spyOn(console, 'log').mockImplementation((...args) => logs.push(args.join(' ')));
38
- await statusCommand({ apiKey: 'test-key' }, [], chalk);
39
- spy.mockRestore();
40
- return logs.join('\n');
41
- }
42
-
43
- describe('statusCommand', () => {
44
- for (const status of ['running', 'provisioning', 'starting', 'queued']) {
45
- it(`shows a deployment in "${status}" status as active, not hidden`, async () => {
46
- const out = await runStatus([makeDep({ status })]);
47
- expect(out).not.toContain('Nothing running');
48
- expect(out).toContain('dep-001');
49
- });
50
- }
51
-
52
- for (const status of ['completed', 'succeeded', 'success', 'failed', 'stopped']) {
53
- it(`excludes a deployment in terminal status "${status}"`, async () => {
54
- const out = await runStatus([makeDep({ status })]);
55
- expect(out).toContain('Nothing running');
56
- expect(out).not.toContain('dep-001');
57
- });
58
- }
59
-
60
- it('reports "Nothing running" when there are no deployments at all', async () => {
61
- const out = await runStatus([]);
62
- expect(out).toContain('Nothing running');
63
- });
64
-
65
- it('shows the running badge for status "running" and a starting-style badge otherwise', async () => {
66
- const out = await runStatus([
67
- makeDep({ deployment_id: 'dep-run', status: 'running' }),
68
- makeDep({ deployment_id: 'dep-start', status: 'starting' }),
69
- ]);
70
- expect(out).toContain('running');
71
- expect(out).toContain('starting');
72
- });
73
- });
@@ -1,187 +0,0 @@
1
- import { describe, it, expect, afterEach } from 'vitest';
2
- import { tmpdir } from 'os';
3
- import { join } from 'path';
4
- import { rmSync, existsSync } from 'fs';
5
- import {
6
- loadStore, saveStore,
7
- addDeployment, updateDeployment, removeDeployment, findDeployment, listDeployments,
8
- addReceipt, updateReceipt, listReceipts, findReceipt,
9
- generateDeploymentId, generateReceiptId,
10
- } from '../src/store.js';
11
-
12
- const tmp = join(tmpdir(), `gpu-store-test-${process.pid}`);
13
- const file = join(tmp, 'deployments.json');
14
-
15
- afterEach(() => {
16
- if (existsSync(tmp)) rmSync(tmp, { recursive: true, force: true });
17
- });
18
-
19
- const makeDep = (overrides = {}) => ({
20
- id: generateDeploymentId(),
21
- name: 'test-dep',
22
- type: 'endpoint',
23
- gpu: 'RTX_4090',
24
- count: 1,
25
- status: 'running',
26
- provider: 'vastai',
27
- endpointUrl: 'https://api.gpu.ai/v1',
28
- createdAt: new Date().toISOString(),
29
- ...overrides,
30
- });
31
-
32
- describe('generateDeploymentId / generateReceiptId', () => {
33
- it('deploymentId starts with dep-', () => {
34
- expect(generateDeploymentId()).toMatch(/^dep-[a-f0-9]{8}$/);
35
- });
36
-
37
- it('receiptId starts with rcpt-', () => {
38
- expect(generateReceiptId()).toMatch(/^rcpt-[a-f0-9]{10}$/);
39
- });
40
-
41
- it('generates unique IDs', () => {
42
- const ids = new Set(Array.from({ length: 50 }, () => generateDeploymentId()));
43
- expect(ids.size).toBe(50);
44
- });
45
- });
46
-
47
- describe('loadStore / saveStore', () => {
48
- it('returns empty store when file does not exist', () => {
49
- const store = loadStore('/nonexistent/path/deps.json');
50
- expect(store.deployments).toEqual([]);
51
- expect(store.receipts).toEqual([]);
52
- });
53
-
54
- it('round-trips data', () => {
55
- const data = { deployments: [makeDep()], receipts: [] };
56
- saveStore(data, file);
57
- expect(loadStore(file).deployments).toHaveLength(1);
58
- });
59
- });
60
-
61
- describe('addDeployment / listDeployments / findDeployment', () => {
62
- it('adds and retrieves by id', () => {
63
- const dep = makeDep({ id: 'dep-aaa' });
64
- addDeployment(dep, file);
65
- expect(findDeployment('dep-aaa', file)).toMatchObject({ id: 'dep-aaa' });
66
- });
67
-
68
- it('retrieves by name', () => {
69
- addDeployment(makeDep({ name: 'my-model' }), file);
70
- expect(findDeployment('my-model', file)).toMatchObject({ name: 'my-model' });
71
- });
72
-
73
- it('returns null for missing deployment', () => {
74
- expect(findDeployment('not-there', file)).toBeNull();
75
- });
76
-
77
- it('listDeployments returns all', () => {
78
- addDeployment(makeDep({ id: 'dep-1', name: 'd1' }), file);
79
- addDeployment(makeDep({ id: 'dep-2', name: 'd2' }), file);
80
- expect(listDeployments(file)).toHaveLength(2);
81
- });
82
- });
83
-
84
- describe('updateDeployment', () => {
85
- it('updates status field', () => {
86
- const dep = makeDep({ id: 'dep-upd' });
87
- addDeployment(dep, file);
88
- const updated = updateDeployment('dep-upd', { status: 'stopped' }, file);
89
- expect(updated.status).toBe('stopped');
90
- expect(findDeployment('dep-upd', file).status).toBe('stopped');
91
- });
92
-
93
- it('returns null for missing deployment', () => {
94
- expect(updateDeployment('dep-missing', { status: 'stopped' }, file)).toBeNull();
95
- });
96
- });
97
-
98
- describe('removeDeployment', () => {
99
- it('removes and returns deployment', () => {
100
- addDeployment(makeDep({ id: 'dep-rm' }), file);
101
- const removed = removeDeployment('dep-rm', file);
102
- expect(removed.id).toBe('dep-rm');
103
- expect(findDeployment('dep-rm', file)).toBeNull();
104
- });
105
-
106
- it('returns null when not found', () => {
107
- expect(removeDeployment('dep-missing', file)).toBeNull();
108
- });
109
- });
110
-
111
- describe('addReceipt / listReceipts', () => {
112
- it('stores and retrieves receipts newest-first', () => {
113
- addReceipt({ receiptId: 'r1', action: 'gpu up' }, file);
114
- addReceipt({ receiptId: 'r2', action: 'gpu down' }, file);
115
- const receipts = listReceipts(10, file);
116
- expect(receipts[0].receiptId).toBe('r2'); // newest first
117
- expect(receipts[1].receiptId).toBe('r1');
118
- });
119
-
120
- it('respects limit', () => {
121
- for (let i = 0; i < 5; i++) {
122
- addReceipt({ receiptId: `r${i}`, action: 'gpu up' }, file);
123
- }
124
- expect(listReceipts(3, file)).toHaveLength(3);
125
- });
126
- });
127
-
128
- describe('findReceipt', () => {
129
- // comfy.batch / train.lora mint a local rcpt- id that points at a job_id —
130
- // `badgr receipts <id>` needs to resolve either the receipt id itself or
131
- // the job_id back to the same local record (see receipts.js).
132
- it('finds a receipt by its own receiptId', () => {
133
- addReceipt({ receiptId: 'rcpt-1', type: 'comfy.batch', job_id: 'job_abc' }, file);
134
- const found = findReceipt('rcpt-1', file);
135
- expect(found?.job_id).toBe('job_abc');
136
- });
137
-
138
- it('finds a receipt by job_id', () => {
139
- addReceipt({ receiptId: 'rcpt-2', type: 'train.lora', job_id: 'job_def' }, file);
140
- const found = findReceipt('job_def', file);
141
- expect(found?.receiptId).toBe('rcpt-2');
142
- });
143
-
144
- it('returns null when nothing matches', () => {
145
- expect(findReceipt('nope', file)).toBeNull();
146
- });
147
- });
148
-
149
- describe('updateReceipt', () => {
150
- it('merges updates into an existing receipt', () => {
151
- addReceipt({ receiptId: 'r-upd', action: 'badgr run', status: 'running' }, file);
152
- updateReceipt('r-upd', { status: 'completed', finalCost: 0.012, runtimeSeconds: 42 }, file);
153
- const [r] = listReceipts(1, file);
154
- expect(r.status).toBe('completed');
155
- expect(r.finalCost).toBe(0.012);
156
- expect(r.runtimeSeconds).toBe(42);
157
- expect(r.action).toBe('badgr run'); // preserved
158
- });
159
-
160
- it('returns null for unknown receipt id', () => {
161
- expect(updateReceipt('no-such-id', { status: 'done' }, file)).toBeNull();
162
- });
163
-
164
- it('records a serve failure receipt with failureType=infrastructure', () => {
165
- addReceipt({
166
- receiptId: 'r-serve-fail',
167
- action: 'badgr serve',
168
- model: 'meta-llama/Llama-3.1-8B-Instruct',
169
- gpu: 'L40S',
170
- status: 'failed',
171
- failureType: 'infrastructure',
172
- createdAt: new Date().toISOString(),
173
- }, file);
174
- const [r] = listReceipts(1, file);
175
- expect(r.failureType).toBe('infrastructure');
176
- expect(r.action).toBe('badgr serve');
177
- expect(r.status).toBe('failed');
178
- });
179
-
180
- it('updates a serve receipt to health_check_timeout', () => {
181
- addReceipt({ receiptId: 'r-hc', action: 'badgr serve', status: 'provisioning' }, file);
182
- updateReceipt('r-hc', { status: 'health_check_timeout' }, file);
183
- const receipts = listReceipts(10, file);
184
- const r = receipts.find(x => x.receiptId === 'r-hc');
185
- expect(r.status).toBe('health_check_timeout');
186
- });
187
- });
@@ -1,109 +0,0 @@
1
- import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
- import { taskCommand } from '../src/commands/task.js';
3
-
4
- const chalk = { red: s => s, dim: s => s, green: s => s, bold: s => s, yellow: s => s, cyan: s => s };
5
- const config = { apiKey: 'test-key', baseUrl: 'https://example.test/v1' };
6
-
7
- describe('taskCommand', () => {
8
- beforeEach(() => {
9
- process.exitCode = undefined;
10
- vi.spyOn(console, 'log').mockImplementation(() => {});
11
- vi.spyOn(console, 'error').mockImplementation(() => {});
12
- });
13
-
14
- afterEach(() => {
15
- vi.restoreAllMocks();
16
- process.exitCode = undefined;
17
- });
18
-
19
- it('requires a description', async () => {
20
- await taskCommand(config, [], chalk);
21
- expect(process.exitCode).toBe(1);
22
- });
23
-
24
- it('is a thin wrapper: prints the description, then delegates to `badgr launch . -- <command>`', async () => {
25
- await taskCommand(config, [
26
- 'Run the Chromium tests and tell me what failed',
27
- '--dry-run', '--max-cost', '1', '--', 'npm', 'run', 'test:chromium',
28
- ], chalk);
29
-
30
- expect(process.exitCode).toBeUndefined();
31
- const logged = console.log.mock.calls.map(c => c.join(' ')).join('\n');
32
- expect(logged).toContain('Task: Run the Chromium tests and tell me what failed');
33
- expect(logged).toContain('Compute:');
34
- expect(logged).toContain('CPU VM');
35
- expect(logged).toContain('npm run test:chromium');
36
- });
37
-
38
- it('supports an agent prompt as the command', async () => {
39
- await taskCommand(config, [
40
- 'Fix the failing checkout test',
41
- '--dry-run', '--max-cost', '1',
42
- '--', 'claude', '-p', 'Fix the failing checkout test',
43
- ], chalk);
44
-
45
- expect(process.exitCode).toBeUndefined();
46
- const logged = console.log.mock.calls.map(c => c.join(' ')).join('\n');
47
- expect(logged).toContain('claude -p Fix the failing checkout test');
48
- });
49
-
50
- it('bubbles up errors from the underlying launch (e.g. --gpu rejection)', async () => {
51
- await taskCommand(config, [
52
- 'Fix the checkout bug',
53
- '--gpu', 'A100', '--dry-run', '--', 'claude', '-p', 'fix',
54
- ], chalk);
55
- expect(process.exitCode).toBe(1);
56
- const logged = console.error.mock.calls.map(c => c.join(' ')).join('\n');
57
- expect(logged).toContain('CPU VM');
58
- });
59
-
60
- it('bubbles up a missing-command error the same way badgr launch would', async () => {
61
- await taskCommand(config, ['Fix the checkout bug'], chalk);
62
- expect(process.exitCode).toBe(1);
63
- });
64
-
65
- it('accepts the quoted --cmd form as the command', async () => {
66
- await taskCommand(config, [
67
- 'Run tests',
68
- '--dry-run', '--max-cost', '1', '--cmd', 'npm run test:chromium',
69
- ], chalk);
70
- expect(process.exitCode).toBeUndefined();
71
- const logged = console.log.mock.calls.map(c => c.join(' ')).join('\n');
72
- expect(logged).toContain('npm run test:chromium');
73
- });
74
-
75
- it('always launches against the current directory, never the description text', async () => {
76
- await taskCommand(config, [
77
- 'Run the Chromium tests',
78
- '--dry-run', '--max-cost', '1', '--', 'npm', 'test',
79
- ], chalk);
80
- expect(process.exitCode).toBeUndefined();
81
- const logged = console.log.mock.calls.map(c => c.join(' ')).join('\n');
82
- expect(logged).not.toContain('Source: Run the Chromium tests');
83
- });
84
-
85
- it('forwards --artifacts through to the underlying launch', async () => {
86
- await taskCommand(config, [
87
- 'Run the Chromium tests and tell me what failed',
88
- '--dry-run', '--max-cost', '1',
89
- '--artifacts', 'playwright-report',
90
- '--', 'npx', 'playwright', 'test',
91
- ], chalk);
92
- expect(process.exitCode).toBeUndefined();
93
- const logged = console.log.mock.calls.map(c => c.join(' ')).join('\n');
94
- expect(logged).toContain('Artifacts:');
95
- expect(logged).toContain('playwright-report');
96
- });
97
-
98
- it('rejects a flag placed before the description instead of silently treating it as the task text', async () => {
99
- // Regression: `badgr task --max-cost 1 "fix" -- npm test` used to treat
100
- // "--max-cost" itself as the description ("Task: --max-cost"), then fail
101
- // downstream with a confusing "Unexpected extra arguments" error that
102
- // never explained the real cause.
103
- await taskCommand(config, ['--max-cost', '1', 'Fix login bug', '--', 'npm', 'test'], chalk);
104
- expect(process.exitCode).toBe(1);
105
- const logged = console.error.mock.calls.map(c => c.join(' ')).join('\n');
106
- expect(logged).toContain('description must come first');
107
- expect(console.log).not.toHaveBeenCalledWith(expect.stringContaining('Task: --max-cost'));
108
- });
109
- });