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
package/tests/browser.test.js
DELETED
|
@@ -1,51 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Regression test for a real command-injection vector: openBrowser used to
|
|
3
|
-
* build a shell command via string interpolation (`execSync(\`open "${url}"\`)`).
|
|
4
|
-
* url is not always a local constant — onboarding.js passes
|
|
5
|
-
* session.login_url, a backend API response — so a response containing
|
|
6
|
-
* shell metacharacters (e.g. a double quote followed by a command) would
|
|
7
|
-
* have executed arbitrary shell commands on the user's machine. Every test
|
|
8
|
-
* that exercises onboarding.js mocks browser.js entirely, so this file
|
|
9
|
-
* itself was never actually run by any existing test.
|
|
10
|
-
*
|
|
11
|
-
* Fixed with execFileSync (argv array, no shell) — this test asserts the
|
|
12
|
-
* fix directly: no shell ever sees url as a string to parse.
|
|
13
|
-
*/
|
|
14
|
-
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
15
|
-
|
|
16
|
-
const mockExecFileSync = vi.fn();
|
|
17
|
-
vi.mock('child_process', () => ({ execFileSync: (...args) => mockExecFileSync(...args) }));
|
|
18
|
-
|
|
19
|
-
const { openBrowser } = await import('../src/browser.js');
|
|
20
|
-
|
|
21
|
-
describe('openBrowser', () => {
|
|
22
|
-
const maliciousUrl = 'https://example.com/"; touch /tmp/pwned; echo "';
|
|
23
|
-
|
|
24
|
-
beforeEach(() => {
|
|
25
|
-
mockExecFileSync.mockReset();
|
|
26
|
-
});
|
|
27
|
-
|
|
28
|
-
it('darwin: passes the URL as a single argv element, never a shell string', () => {
|
|
29
|
-
vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin');
|
|
30
|
-
openBrowser(maliciousUrl);
|
|
31
|
-
expect(mockExecFileSync).toHaveBeenCalledWith('open', [maliciousUrl]);
|
|
32
|
-
});
|
|
33
|
-
|
|
34
|
-
it('linux: passes the URL as a single argv element, never a shell string', () => {
|
|
35
|
-
vi.spyOn(process, 'platform', 'get').mockReturnValue('linux');
|
|
36
|
-
openBrowser(maliciousUrl);
|
|
37
|
-
expect(mockExecFileSync).toHaveBeenCalledWith('xdg-open', [maliciousUrl]);
|
|
38
|
-
});
|
|
39
|
-
|
|
40
|
-
it('win32: passes the URL as a single argv element, never a shell string', () => {
|
|
41
|
-
vi.spyOn(process, 'platform', 'get').mockReturnValue('win32');
|
|
42
|
-
openBrowser(maliciousUrl);
|
|
43
|
-
expect(mockExecFileSync).toHaveBeenCalledWith('cmd', ['/c', 'start', '', maliciousUrl]);
|
|
44
|
-
});
|
|
45
|
-
|
|
46
|
-
it('never throws even if execFileSync fails — caller always has the printed URL as fallback', () => {
|
|
47
|
-
vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin');
|
|
48
|
-
mockExecFileSync.mockImplementation(() => { throw new Error('no such command'); });
|
|
49
|
-
expect(() => openBrowser(maliciousUrl)).not.toThrow();
|
|
50
|
-
});
|
|
51
|
-
});
|
package/tests/capacity.test.js
DELETED
|
@@ -1,68 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* badgr capacity — price-cap behavior
|
|
3
|
-
*
|
|
4
|
-
* Regression coverage: `badgr capacity` used to silently default to
|
|
5
|
-
* --max-price 10 and print misleading "under $10.00/hr" messaging even
|
|
6
|
-
* though the user never asked for a cap. It should now omit max_price
|
|
7
|
-
* entirely unless the user passes --max-price, and only mention a price
|
|
8
|
-
* ceiling in output when one was actually requested.
|
|
9
|
-
*/
|
|
10
|
-
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
11
|
-
|
|
12
|
-
vi.mock('../src/api.js', () => ({
|
|
13
|
-
callApi: vi.fn(),
|
|
14
|
-
}));
|
|
15
|
-
|
|
16
|
-
import * as api from '../src/api.js';
|
|
17
|
-
import { capacityCommand } from '../src/commands/capacity.js';
|
|
18
|
-
|
|
19
|
-
const config = { apiKey: 'test-key', baseUrl: 'https://api.test' };
|
|
20
|
-
const chalk = new Proxy({}, { get: () => (s) => s });
|
|
21
|
-
|
|
22
|
-
function urlParams(callIndex = 0) {
|
|
23
|
-
const url = api.callApi.mock.calls[callIndex][0];
|
|
24
|
-
return new URLSearchParams(url.split('?')[1] ?? '');
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
describe('capacityCommand price cap', () => {
|
|
28
|
-
beforeEach(() => {
|
|
29
|
-
api.callApi.mockReset();
|
|
30
|
-
});
|
|
31
|
-
|
|
32
|
-
it('omits max_price from /capacity/auto when --max-price is not passed', async () => {
|
|
33
|
-
api.callApi.mockResolvedValueOnce({ gpu: 'RTX_4090', region: 'US', price: 0.17 });
|
|
34
|
-
await capacityCommand(config, [], chalk);
|
|
35
|
-
expect(urlParams().has('max_price')).toBe(false);
|
|
36
|
-
});
|
|
37
|
-
|
|
38
|
-
it('sends max_price on /capacity/auto when --max-price is passed', async () => {
|
|
39
|
-
api.callApi.mockResolvedValueOnce({ gpu: 'RTX_4090', region: 'US', price: 0.17 });
|
|
40
|
-
await capacityCommand(config, ['--max-price', '5'], chalk);
|
|
41
|
-
expect(urlParams().get('max_price')).toBe('5');
|
|
42
|
-
});
|
|
43
|
-
|
|
44
|
-
it('omits max_price from /capacity/suggestions when --max-price is not passed', async () => {
|
|
45
|
-
api.callApi.mockResolvedValueOnce({ matches: [], alternatives: [] });
|
|
46
|
-
await capacityCommand(config, ['--gpu', '5090'], chalk);
|
|
47
|
-
expect(urlParams().has('max_price')).toBe(false);
|
|
48
|
-
});
|
|
49
|
-
|
|
50
|
-
it('does not mention a price ceiling in the no-match message when none was requested', async () => {
|
|
51
|
-
api.callApi.mockResolvedValueOnce({ matches: [], alternatives: [] });
|
|
52
|
-
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
53
|
-
await capacityCommand(config, ['--gpu', '5090'], chalk);
|
|
54
|
-
const output = logSpy.mock.calls.map((c) => c.join(' ')).join('\n');
|
|
55
|
-
logSpy.mockRestore();
|
|
56
|
-
expect(output).toContain('No 5090 available right now.');
|
|
57
|
-
expect(output).not.toContain('under $');
|
|
58
|
-
});
|
|
59
|
-
|
|
60
|
-
it('mentions the price ceiling in the no-match message when --max-price was passed', async () => {
|
|
61
|
-
api.callApi.mockResolvedValueOnce({ matches: [], alternatives: [] });
|
|
62
|
-
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
63
|
-
await capacityCommand(config, ['--gpu', '5090', '--max-price', '10'], chalk);
|
|
64
|
-
const output = logSpy.mock.calls.map((c) => c.join(' ')).join('\n');
|
|
65
|
-
logSpy.mockRestore();
|
|
66
|
-
expect(output).toContain('under $10.00/hr');
|
|
67
|
-
});
|
|
68
|
-
});
|
package/tests/commands.test.js
DELETED
|
@@ -1,417 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
-
import { parseRunArgs, classifyFailure } from '../src/commands/run.js';
|
|
3
|
-
import { parseServeArgs } from '../src/commands/serve.js';
|
|
4
|
-
import { testCommand, parseTestArgs } from '../src/commands/test-run.js';
|
|
5
|
-
import { rankAlternatives, diffDescription, promptFallback, CapacityError } from '../src/fallback.js';
|
|
6
|
-
|
|
7
|
-
describe('parseRunArgs', () => {
|
|
8
|
-
it('parses a plain command', () => {
|
|
9
|
-
const { flags, positional } = parseRunArgs(['python', 'train.py']);
|
|
10
|
-
expect(positional).toEqual(['python', 'train.py']);
|
|
11
|
-
expect(flags.gpu).toBeUndefined();
|
|
12
|
-
});
|
|
13
|
-
|
|
14
|
-
it('parses --gpu flag', () => {
|
|
15
|
-
const { flags, positional } = parseRunArgs(['python', 'train.py', '--gpu', 'A100']);
|
|
16
|
-
expect(positional).toEqual(['python', 'train.py']);
|
|
17
|
-
expect(flags.gpu).toBe('A100');
|
|
18
|
-
});
|
|
19
|
-
|
|
20
|
-
it('parses --image flag', () => {
|
|
21
|
-
const { flags } = parseRunArgs(['--image', 'my/image:latest', '--gpu', 'L40S']);
|
|
22
|
-
expect(flags.image).toBe('my/image:latest');
|
|
23
|
-
expect(flags.gpu).toBe('L40S');
|
|
24
|
-
});
|
|
25
|
-
|
|
26
|
-
it('parses --max-price flag', () => {
|
|
27
|
-
const { flags } = parseRunArgs(['python', 'run.py', '--max-price', '2.5']);
|
|
28
|
-
expect(flags.maxPrice).toBe(2.5);
|
|
29
|
-
});
|
|
30
|
-
|
|
31
|
-
it('parses --detach flag', () => {
|
|
32
|
-
const { flags, positional } = parseRunArgs(['python', 'train.py', '--gpu', 'A100', '--detach']);
|
|
33
|
-
expect(positional).toEqual(['python', 'train.py']);
|
|
34
|
-
expect(flags.detach).toBe(true);
|
|
35
|
-
expect(flags.gpu).toBe('A100');
|
|
36
|
-
});
|
|
37
|
-
|
|
38
|
-
it('detach defaults to falsy when not passed', () => {
|
|
39
|
-
const { flags } = parseRunArgs(['python', 'train.py']);
|
|
40
|
-
expect(flags.detach).toBeFalsy();
|
|
41
|
-
});
|
|
42
|
-
|
|
43
|
-
it('returns empty positional and flags for empty args', () => {
|
|
44
|
-
const { flags, positional } = parseRunArgs([]);
|
|
45
|
-
expect(positional).toEqual([]);
|
|
46
|
-
expect(flags.gpu).toBeUndefined();
|
|
47
|
-
});
|
|
48
|
-
|
|
49
|
-
it('parses --fallback closest', () => {
|
|
50
|
-
const { flags } = parseRunArgs(['python', 'train.py', '--gpu', 'A100', '--fallback', 'closest']);
|
|
51
|
-
expect(flags.fallback).toBe('closest');
|
|
52
|
-
expect(flags.noFallback).toBeUndefined();
|
|
53
|
-
});
|
|
54
|
-
|
|
55
|
-
it('parses --fallback cheapest', () => {
|
|
56
|
-
const { flags } = parseRunArgs(['python', 'train.py', '--fallback', 'cheapest']);
|
|
57
|
-
expect(flags.fallback).toBe('cheapest');
|
|
58
|
-
});
|
|
59
|
-
|
|
60
|
-
it('parses --no-fallback', () => {
|
|
61
|
-
const { flags } = parseRunArgs(['python', 'train.py', '--gpu', 'A100', '--no-fallback']);
|
|
62
|
-
expect(flags.noFallback).toBe(true);
|
|
63
|
-
expect(flags.fallback).toBeUndefined();
|
|
64
|
-
});
|
|
65
|
-
|
|
66
|
-
it('parses --max-runtime as a float (minutes)', () => {
|
|
67
|
-
const { flags } = parseRunArgs(['python', 'train.py', '--max-runtime', '30']);
|
|
68
|
-
expect(flags.maxRuntime).toBe(30);
|
|
69
|
-
});
|
|
70
|
-
|
|
71
|
-
it('parses --max-cost as a float (dollars)', () => {
|
|
72
|
-
const { flags } = parseRunArgs(['python', 'train.py', '--max-cost', '5.00']);
|
|
73
|
-
expect(flags.maxCost).toBe(5.0);
|
|
74
|
-
});
|
|
75
|
-
|
|
76
|
-
it('parses --tier 2 flag', () => {
|
|
77
|
-
const { flags } = parseRunArgs(['python', 'train.py', '--tier', '2']);
|
|
78
|
-
expect(flags.tier).toBe('2');
|
|
79
|
-
});
|
|
80
|
-
|
|
81
|
-
it('--tier defaults to undefined when not passed', () => {
|
|
82
|
-
const { flags } = parseRunArgs(['python', 'train.py']);
|
|
83
|
-
expect(flags.tier).toBeUndefined();
|
|
84
|
-
});
|
|
85
|
-
|
|
86
|
-
it('parses --min-vram flag', () => {
|
|
87
|
-
const { flags } = parseRunArgs(['python', 'job.py', '--min-vram', '24']);
|
|
88
|
-
expect(flags.minVram).toBe(24);
|
|
89
|
-
expect(flags.gpu).toBeUndefined();
|
|
90
|
-
});
|
|
91
|
-
|
|
92
|
-
it('--min-vram and --gpu can be combined', () => {
|
|
93
|
-
const { flags } = parseRunArgs(['python', 'job.py', '--gpu', 'A100', '--min-vram', '40']);
|
|
94
|
-
expect(flags.gpu).toBe('A100');
|
|
95
|
-
expect(flags.minVram).toBe(40);
|
|
96
|
-
});
|
|
97
|
-
|
|
98
|
-
it('parses --gpu-memory as a GB-parsed alias for --min-vram', () => {
|
|
99
|
-
const { flags } = parseRunArgs(['python', 'job.py', '--gpu-memory', '24GB']);
|
|
100
|
-
expect(flags.minVram).toBe(24);
|
|
101
|
-
});
|
|
102
|
-
|
|
103
|
-
it('--gpu-memory accepts a bare number (assumed GB)', () => {
|
|
104
|
-
const { flags } = parseRunArgs(['python', 'job.py', '--gpu-memory', '40']);
|
|
105
|
-
expect(flags.minVram).toBe(40);
|
|
106
|
-
});
|
|
107
|
-
|
|
108
|
-
it('parses --cpu as an integer core count', () => {
|
|
109
|
-
const { flags } = parseRunArgs(['python', 'job.py', '--cpu', '16']);
|
|
110
|
-
expect(flags.cpu).toBe(16);
|
|
111
|
-
});
|
|
112
|
-
|
|
113
|
-
it('parses --memory with a GB/MB suffix into a GB float', () => {
|
|
114
|
-
const { flags: gb } = parseRunArgs(['python', 'job.py', '--memory', '64GB']);
|
|
115
|
-
expect(gb.memory).toBe(64);
|
|
116
|
-
const { flags: mb } = parseRunArgs(['python', 'job.py', '--memory', '65536MB']);
|
|
117
|
-
expect(mb.memory).toBeCloseTo(64);
|
|
118
|
-
});
|
|
119
|
-
|
|
120
|
-
it('--cpu, --memory, --gpu-memory can all be combined', () => {
|
|
121
|
-
const { flags } = parseRunArgs(['python', 'job.py', '--cpu', '16', '--memory', '64GB', '--gpu-memory', '24GB']);
|
|
122
|
-
expect(flags.cpu).toBe(16);
|
|
123
|
-
expect(flags.memory).toBe(64);
|
|
124
|
-
expect(flags.minVram).toBe(24);
|
|
125
|
-
});
|
|
126
|
-
|
|
127
|
-
it('parses --no-gpu', () => {
|
|
128
|
-
const { flags } = parseRunArgs(['python', 'sim.py', '--no-gpu', '--cpu', '16', '--memory', '64GB']);
|
|
129
|
-
expect(flags.noGpu).toBe(true);
|
|
130
|
-
expect(flags.cpu).toBe(16);
|
|
131
|
-
expect(flags.memory).toBe(64);
|
|
132
|
-
});
|
|
133
|
-
|
|
134
|
-
it('-- separator: badgr flags before --, command argv after', () => {
|
|
135
|
-
const { flags, commandArgv, positional } = parseRunArgs([
|
|
136
|
-
'--gpu', 'RTX_4090', '--image', 'node:20', '--max-cost', '1', '--',
|
|
137
|
-
'node', '-e', "console.log('hello')",
|
|
138
|
-
]);
|
|
139
|
-
expect(flags.gpu).toBe('RTX_4090');
|
|
140
|
-
expect(flags.image).toBe('node:20');
|
|
141
|
-
expect(flags.maxCost).toBe(1);
|
|
142
|
-
expect(commandArgv).toEqual(['node', '-e', "console.log('hello')"]);
|
|
143
|
-
expect(positional).toEqual([]);
|
|
144
|
-
});
|
|
145
|
-
|
|
146
|
-
it('-- separator: commandArgv is empty array when nothing follows --', () => {
|
|
147
|
-
const { commandArgv } = parseRunArgs(['--gpu', 'A100', '--max-cost', '2', '--']);
|
|
148
|
-
expect(commandArgv).toEqual([]);
|
|
149
|
-
});
|
|
150
|
-
|
|
151
|
-
it('commandArgv is null when -- is not used (legacy syntax)', () => {
|
|
152
|
-
const { commandArgv, positional } = parseRunArgs(['python', 'train.py', '--gpu', 'A100']);
|
|
153
|
-
expect(commandArgv).toBeNull();
|
|
154
|
-
expect(positional).toEqual(['python', 'train.py']);
|
|
155
|
-
});
|
|
156
|
-
|
|
157
|
-
it('parses --dry-run flag', () => {
|
|
158
|
-
const { flags } = parseRunArgs(['--dry-run', '--gpu', 'RTX_4090', '--image', 'node:20']);
|
|
159
|
-
expect(flags.dryRun).toBe(true);
|
|
160
|
-
expect(flags.gpu).toBe('RTX_4090');
|
|
161
|
-
});
|
|
162
|
-
|
|
163
|
-
it('--dry-run with -- separator keeps command argv intact', () => {
|
|
164
|
-
const { flags, commandArgv } = parseRunArgs([
|
|
165
|
-
'--dry-run', '--gpu', 'RTX_4090', '--image', 'node:20', '--',
|
|
166
|
-
'node', '-e', "console.log('dry')",
|
|
167
|
-
]);
|
|
168
|
-
expect(flags.dryRun).toBe(true);
|
|
169
|
-
expect(commandArgv).toEqual(['node', '-e', "console.log('dry')"]);
|
|
170
|
-
});
|
|
171
|
-
|
|
172
|
-
it('-- separator: command tokens with --flags are not parsed as badgr flags', () => {
|
|
173
|
-
// --json is a user command flag here, not a badgr flag
|
|
174
|
-
const { flags, commandArgv } = parseRunArgs([
|
|
175
|
-
'--gpu', 'A100', '--max-cost', '2', '--',
|
|
176
|
-
'gpu-monitor', 'check', '--json',
|
|
177
|
-
]);
|
|
178
|
-
expect(commandArgv).toEqual(['gpu-monitor', 'check', '--json']);
|
|
179
|
-
expect(flags.gpu).toBe('A100');
|
|
180
|
-
});
|
|
181
|
-
});
|
|
182
|
-
|
|
183
|
-
describe('classifyFailure', () => {
|
|
184
|
-
it('returns infrastructure when status is failed and no exit code', () => {
|
|
185
|
-
expect(classifyFailure('failed', null)).toBe('infrastructure');
|
|
186
|
-
expect(classifyFailure('failed', undefined)).toBe('infrastructure');
|
|
187
|
-
});
|
|
188
|
-
|
|
189
|
-
it('returns customer_code when exit code is non-zero', () => {
|
|
190
|
-
expect(classifyFailure('failed', 1)).toBe('customer_code');
|
|
191
|
-
expect(classifyFailure('completed', 2)).toBe('customer_code');
|
|
192
|
-
});
|
|
193
|
-
|
|
194
|
-
it('returns null for successful jobs', () => {
|
|
195
|
-
expect(classifyFailure('completed', 0)).toBeNull();
|
|
196
|
-
expect(classifyFailure('completed', null)).toBeNull();
|
|
197
|
-
});
|
|
198
|
-
|
|
199
|
-
it('returns null for stopped jobs with no exit code', () => {
|
|
200
|
-
expect(classifyFailure('stopped', null)).toBeNull();
|
|
201
|
-
});
|
|
202
|
-
});
|
|
203
|
-
|
|
204
|
-
describe('rankAlternatives', () => {
|
|
205
|
-
const pool = [
|
|
206
|
-
{ gpu: 'RTX_4090', region: 'US', price: 0.72, rank: 3 },
|
|
207
|
-
{ gpu: 'L40S', region: 'US', price: 1.25, rank: 2 },
|
|
208
|
-
{ gpu: 'H100', region: 'EU', price: 2.80, rank: 1 },
|
|
209
|
-
{ gpu: 'A6000', region: 'US', price: 1.20 }, // no rank field
|
|
210
|
-
];
|
|
211
|
-
|
|
212
|
-
it('cheapest mode sorts by price ascending', () => {
|
|
213
|
-
const ranked = rankAlternatives('A100', pool, 'cheapest');
|
|
214
|
-
expect(ranked[0].price).toBe(0.72);
|
|
215
|
-
expect(ranked[ranked.length - 1].price).toBe(2.80);
|
|
216
|
-
});
|
|
217
|
-
|
|
218
|
-
it('closest mode honours backend rank field when present', () => {
|
|
219
|
-
const poolWithRanks = [
|
|
220
|
-
{ gpu: 'RTX_4090', region: 'US', price: 0.72, rank: 3 },
|
|
221
|
-
{ gpu: 'L40S', region: 'US', price: 1.25, rank: 2 },
|
|
222
|
-
{ gpu: 'H100', region: 'EU', price: 2.80, rank: 1 },
|
|
223
|
-
];
|
|
224
|
-
const ranked = rankAlternatives('A100', poolWithRanks, 'closest');
|
|
225
|
-
expect(ranked[0].gpu).toBe('H100'); // rank 1 → top
|
|
226
|
-
expect(ranked[1].gpu).toBe('L40S'); // rank 2
|
|
227
|
-
expect(ranked[2].gpu).toBe('RTX_4090'); // rank 3
|
|
228
|
-
});
|
|
229
|
-
|
|
230
|
-
it('closest mode falls back to price sort when no rank field', () => {
|
|
231
|
-
const noRankPool = [
|
|
232
|
-
{ gpu: 'RTX_4090', region: 'US', price: 0.72 },
|
|
233
|
-
{ gpu: 'L40S', region: 'US', price: 1.25 },
|
|
234
|
-
];
|
|
235
|
-
const ranked = rankAlternatives('A100', noRankPool, 'closest');
|
|
236
|
-
expect(ranked[0].price).toBe(0.72);
|
|
237
|
-
});
|
|
238
|
-
|
|
239
|
-
it('returns empty array for empty pool', () => {
|
|
240
|
-
expect(rankAlternatives('A100', [])).toEqual([]);
|
|
241
|
-
});
|
|
242
|
-
|
|
243
|
-
it('returns empty array for null pool', () => {
|
|
244
|
-
expect(rankAlternatives('A100', null)).toEqual([]);
|
|
245
|
-
});
|
|
246
|
-
});
|
|
247
|
-
|
|
248
|
-
describe('diffDescription', () => {
|
|
249
|
-
it('uses diff_desc from alt object when backend provides it', () => {
|
|
250
|
-
const alt = { diff_desc: 'less VRAM (24GB vs 40GB) than A100' };
|
|
251
|
-
expect(diffDescription('A100', 'RTX_4090', alt)).toBe(alt.diff_desc);
|
|
252
|
-
});
|
|
253
|
-
|
|
254
|
-
it('returns empty string when diff_desc is absent', () => {
|
|
255
|
-
expect(diffDescription('A100', 'RTX_4090', {})).toBe('');
|
|
256
|
-
expect(diffDescription('A100', 'RTX_4090')).toBe('');
|
|
257
|
-
});
|
|
258
|
-
|
|
259
|
-
it('returns empty string for unknown GPUs with no alt object', () => {
|
|
260
|
-
expect(diffDescription('UNKNOWN', 'ALSO_UNKNOWN')).toBe('');
|
|
261
|
-
});
|
|
262
|
-
});
|
|
263
|
-
|
|
264
|
-
describe('parseServeArgs', () => {
|
|
265
|
-
it('parses model positional arg', () => {
|
|
266
|
-
const { model, flags } = parseServeArgs(['meta-llama/Llama-3.1-8B-Instruct', '--gpu', 'L40S']);
|
|
267
|
-
expect(model).toBe('meta-llama/Llama-3.1-8B-Instruct');
|
|
268
|
-
expect(flags.gpu).toBe('L40S');
|
|
269
|
-
});
|
|
270
|
-
|
|
271
|
-
it('parses --name flag', () => {
|
|
272
|
-
const { model, flags } = parseServeArgs(['mistralai/Mistral-7B-v0.1', '--name', 'prod-llm']);
|
|
273
|
-
expect(model).toBe('mistralai/Mistral-7B-v0.1');
|
|
274
|
-
expect(flags.name).toBe('prod-llm');
|
|
275
|
-
});
|
|
276
|
-
|
|
277
|
-
it('parses --max-price flag', () => {
|
|
278
|
-
const { flags } = parseServeArgs(['my/model', '--max-price', '3.0']);
|
|
279
|
-
expect(flags.maxPrice).toBe(3.0);
|
|
280
|
-
});
|
|
281
|
-
|
|
282
|
-
it('parses --no-wait flag', () => {
|
|
283
|
-
const { model, flags } = parseServeArgs(['my/model', '--gpu', 'L40S', '--no-wait']);
|
|
284
|
-
expect(model).toBe('my/model');
|
|
285
|
-
expect(flags.noWait).toBe(true);
|
|
286
|
-
});
|
|
287
|
-
|
|
288
|
-
it('noWait defaults to falsy when not passed', () => {
|
|
289
|
-
const { flags } = parseServeArgs(['my/model']);
|
|
290
|
-
expect(flags.noWait).toBeFalsy();
|
|
291
|
-
});
|
|
292
|
-
|
|
293
|
-
it('returns null model when no positional arg', () => {
|
|
294
|
-
const { model } = parseServeArgs(['--gpu', 'RTX_4090']);
|
|
295
|
-
expect(model).toBeNull();
|
|
296
|
-
});
|
|
297
|
-
|
|
298
|
-
it('parses --tier 2 flag', () => {
|
|
299
|
-
const { flags } = parseServeArgs(['my/model', '--tier', '2']);
|
|
300
|
-
expect(flags.tier).toBe('2');
|
|
301
|
-
});
|
|
302
|
-
|
|
303
|
-
it('--tier defaults to undefined when not passed', () => {
|
|
304
|
-
const { flags } = parseServeArgs(['my/model']);
|
|
305
|
-
expect(flags.tier).toBeUndefined();
|
|
306
|
-
});
|
|
307
|
-
|
|
308
|
-
it('parses --no-fallback as noMarketplaceFallback', () => {
|
|
309
|
-
const { flags } = parseServeArgs(['my/model', '--no-fallback']);
|
|
310
|
-
expect(flags.noMarketplaceFallback).toBe(true);
|
|
311
|
-
});
|
|
312
|
-
|
|
313
|
-
it('parses --strict-capacity as noMarketplaceFallback', () => {
|
|
314
|
-
const { flags } = parseServeArgs(['my/model', '--strict-capacity']);
|
|
315
|
-
expect(flags.noMarketplaceFallback).toBe(true);
|
|
316
|
-
});
|
|
317
|
-
|
|
318
|
-
it('parses --no-expanded-search as noMarketplaceFallback', () => {
|
|
319
|
-
const { flags } = parseServeArgs(['my/model', '--no-expanded-search']);
|
|
320
|
-
expect(flags.noMarketplaceFallback).toBe(true);
|
|
321
|
-
});
|
|
322
|
-
|
|
323
|
-
it('noMarketplaceFallback defaults to falsy when not passed', () => {
|
|
324
|
-
const { flags } = parseServeArgs(['my/model']);
|
|
325
|
-
expect(flags.noMarketplaceFallback).toBeFalsy();
|
|
326
|
-
});
|
|
327
|
-
|
|
328
|
-
it('model is not consumed by --no-fallback flag', () => {
|
|
329
|
-
const { model, flags } = parseServeArgs(['my/model', '--no-fallback']);
|
|
330
|
-
expect(model).toBe('my/model');
|
|
331
|
-
expect(flags.noMarketplaceFallback).toBe(true);
|
|
332
|
-
});
|
|
333
|
-
|
|
334
|
-
it('parses --health-path flag', () => {
|
|
335
|
-
const { flags } = parseServeArgs(['my/model', '--health-path', '/system_stats']);
|
|
336
|
-
expect(flags.healthPath).toBe('/system_stats');
|
|
337
|
-
});
|
|
338
|
-
|
|
339
|
-
it('parses --health-path with arbitrary path', () => {
|
|
340
|
-
const { flags } = parseServeArgs(['--image', 'my/image:latest', '--health-path', '/health']);
|
|
341
|
-
expect(flags.healthPath).toBe('/health');
|
|
342
|
-
});
|
|
343
|
-
|
|
344
|
-
it('healthPath defaults to undefined when not passed', () => {
|
|
345
|
-
const { flags } = parseServeArgs(['my/model']);
|
|
346
|
-
expect(flags.healthPath).toBeUndefined();
|
|
347
|
-
});
|
|
348
|
-
});
|
|
349
|
-
|
|
350
|
-
describe('testCommand', () => {
|
|
351
|
-
it('is a function', () => {
|
|
352
|
-
expect(typeof testCommand).toBe('function');
|
|
353
|
-
});
|
|
354
|
-
});
|
|
355
|
-
|
|
356
|
-
describe('parseTestArgs', () => {
|
|
357
|
-
it('returns empty flags for no args', () => {
|
|
358
|
-
expect(parseTestArgs([])).toEqual({});
|
|
359
|
-
});
|
|
360
|
-
|
|
361
|
-
it('parses --provider tier1', () => {
|
|
362
|
-
expect(parseTestArgs(['--provider', 'tier1'])).toEqual({ provider: 'tier1' });
|
|
363
|
-
});
|
|
364
|
-
|
|
365
|
-
it('parses --provider tier2', () => {
|
|
366
|
-
expect(parseTestArgs(['--provider', 'tier2'])).toEqual({ provider: 'tier2' });
|
|
367
|
-
});
|
|
368
|
-
|
|
369
|
-
it('parses --provider secondary', () => {
|
|
370
|
-
expect(parseTestArgs(['--provider', 'secondary'])).toEqual({ provider: 'secondary' });
|
|
371
|
-
});
|
|
372
|
-
|
|
373
|
-
it('parses --no-tier-fallback', () => {
|
|
374
|
-
expect(parseTestArgs(['--no-tier-fallback'])).toEqual({ noTierFallback: true });
|
|
375
|
-
});
|
|
376
|
-
});
|
|
377
|
-
|
|
378
|
-
describe('CapacityError', () => {
|
|
379
|
-
it('is an Error subclass', () => {
|
|
380
|
-
const err = new CapacityError('no capacity');
|
|
381
|
-
expect(err).toBeInstanceOf(Error);
|
|
382
|
-
expect(err.isCapacityError).toBe(true);
|
|
383
|
-
expect(err.name).toBe('CapacityError');
|
|
384
|
-
expect(err.message).toBe('no capacity');
|
|
385
|
-
});
|
|
386
|
-
});
|
|
387
|
-
|
|
388
|
-
describe('promptFallback output', () => {
|
|
389
|
-
it('does not print a Difference line', async () => {
|
|
390
|
-
const lines = [];
|
|
391
|
-
const chalk = { yellow: s => s, bold: s => s, cyan: s => s, green: s => s, dim: s => s };
|
|
392
|
-
const origLog = console.log;
|
|
393
|
-
console.log = (...args) => lines.push(args.join(' '));
|
|
394
|
-
// force non-TTY so it auto-selects without asking
|
|
395
|
-
const origIsTTY = process.stdin.isTTY;
|
|
396
|
-
process.stdin.isTTY = false;
|
|
397
|
-
|
|
398
|
-
const pool = [{ gpu: 'L40S', region: 'US', price: 1.25 }];
|
|
399
|
-
await promptFallback('A100', pool, chalk);
|
|
400
|
-
|
|
401
|
-
console.log = origLog;
|
|
402
|
-
process.stdin.isTTY = origIsTTY;
|
|
403
|
-
|
|
404
|
-
const joined = lines.join('\n');
|
|
405
|
-
expect(joined).not.toContain('Difference');
|
|
406
|
-
expect(joined).toContain('L40S');
|
|
407
|
-
expect(joined).toContain('1.25');
|
|
408
|
-
});
|
|
409
|
-
});
|
|
410
|
-
|
|
411
|
-
describe('old agent-positional run path (removed)', () => {
|
|
412
|
-
it('no longer exports isAgentRun/buildAgentRun from run.js', async () => {
|
|
413
|
-
const mod = await import('../src/commands/run.js');
|
|
414
|
-
expect(mod.isAgentRun).toBeUndefined();
|
|
415
|
-
expect(mod.buildAgentRun).toBeUndefined();
|
|
416
|
-
});
|
|
417
|
-
});
|
package/tests/config.test.js
DELETED
|
@@ -1,96 +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 { loadConfig, saveConfig, requireApiKey, normalizeBaseUrl, DEFAULTS } from '../src/config.js';
|
|
6
|
-
|
|
7
|
-
const tmp = join(tmpdir(), `badgr-cli-test-${process.pid}`);
|
|
8
|
-
const testConfigFile = join(tmp, 'config.json');
|
|
9
|
-
|
|
10
|
-
afterEach(() => {
|
|
11
|
-
if (existsSync(tmp)) rmSync(tmp, { recursive: true, force: true });
|
|
12
|
-
});
|
|
13
|
-
|
|
14
|
-
describe('loadConfig', () => {
|
|
15
|
-
it('returns defaults when file does not exist', () => {
|
|
16
|
-
const config = loadConfig('/nonexistent/path/config.json');
|
|
17
|
-
expect(config.baseUrl).toBe(DEFAULTS.baseUrl);
|
|
18
|
-
expect(config.defaultModel).toBe(DEFAULTS.defaultModel);
|
|
19
|
-
expect(config.apiKey).toBeUndefined();
|
|
20
|
-
});
|
|
21
|
-
|
|
22
|
-
it('merges saved values with defaults', () => {
|
|
23
|
-
saveConfig({ apiKey: 'sk-test' }, testConfigFile);
|
|
24
|
-
const config = loadConfig(testConfigFile);
|
|
25
|
-
expect(config.apiKey).toBe('sk-test');
|
|
26
|
-
expect(config.baseUrl).toBe(DEFAULTS.baseUrl);
|
|
27
|
-
});
|
|
28
|
-
|
|
29
|
-
it('returns defaults on corrupt file', () => {
|
|
30
|
-
import('fs').then(({ writeFileSync, mkdirSync }) => {
|
|
31
|
-
mkdirSync(tmp, { recursive: true });
|
|
32
|
-
writeFileSync(testConfigFile, '{ invalid json }');
|
|
33
|
-
});
|
|
34
|
-
const config = loadConfig(testConfigFile);
|
|
35
|
-
expect(config.baseUrl).toBe(DEFAULTS.baseUrl);
|
|
36
|
-
});
|
|
37
|
-
});
|
|
38
|
-
|
|
39
|
-
describe('saveConfig', () => {
|
|
40
|
-
it('persists values and returns merged config', () => {
|
|
41
|
-
const saved = saveConfig({ apiKey: 'sk-123' }, testConfigFile);
|
|
42
|
-
expect(saved.apiKey).toBe('sk-123');
|
|
43
|
-
expect(saved.baseUrl).toBe(DEFAULTS.baseUrl);
|
|
44
|
-
});
|
|
45
|
-
|
|
46
|
-
it('merges with existing values', () => {
|
|
47
|
-
saveConfig({ apiKey: 'original' }, testConfigFile);
|
|
48
|
-
saveConfig({ baseUrl: 'http://localhost:8000/v1' }, testConfigFile);
|
|
49
|
-
const config = loadConfig(testConfigFile);
|
|
50
|
-
expect(config.apiKey).toBe('original');
|
|
51
|
-
expect(config.baseUrl).toBe('http://localhost:8000/v1');
|
|
52
|
-
});
|
|
53
|
-
|
|
54
|
-
it('creates missing directories', () => {
|
|
55
|
-
const deepFile = join(tmp, 'deep', 'nested', 'config.json');
|
|
56
|
-
saveConfig({ apiKey: 'deep' }, deepFile);
|
|
57
|
-
expect(existsSync(deepFile)).toBe(true);
|
|
58
|
-
});
|
|
59
|
-
});
|
|
60
|
-
|
|
61
|
-
describe('normalizeBaseUrl', () => {
|
|
62
|
-
it('rewrites legacy api.badgr.ai to production host', () => {
|
|
63
|
-
expect(normalizeBaseUrl('https://api.badgr.ai/v1')).toBe(DEFAULTS.baseUrl);
|
|
64
|
-
expect(normalizeBaseUrl('https://api.badgr.ai')).toBe(DEFAULTS.baseUrl);
|
|
65
|
-
});
|
|
66
|
-
|
|
67
|
-
it('keeps production and localhost URLs', () => {
|
|
68
|
-
expect(normalizeBaseUrl('https://api.aibadgr.com/v1')).toBe(DEFAULTS.baseUrl);
|
|
69
|
-
expect(normalizeBaseUrl('http://localhost:8000/v1')).toBe('http://localhost:8000/v1');
|
|
70
|
-
});
|
|
71
|
-
});
|
|
72
|
-
|
|
73
|
-
describe('loadConfig legacy migration', () => {
|
|
74
|
-
it('migrates saved api.badgr.ai baseUrl on load', () => {
|
|
75
|
-
saveConfig(
|
|
76
|
-
{ apiKey: 'sk-test', baseUrl: 'https://api.badgr.ai/v1' },
|
|
77
|
-
testConfigFile,
|
|
78
|
-
);
|
|
79
|
-
const config = loadConfig(testConfigFile);
|
|
80
|
-
expect(config.baseUrl).toBe(DEFAULTS.baseUrl);
|
|
81
|
-
});
|
|
82
|
-
});
|
|
83
|
-
|
|
84
|
-
describe('requireApiKey', () => {
|
|
85
|
-
it('returns the key when present', () => {
|
|
86
|
-
expect(requireApiKey({ apiKey: 'sk-abc' })).toBe('sk-abc');
|
|
87
|
-
});
|
|
88
|
-
|
|
89
|
-
it('throws an error mentioning `badgr login` when missing', () => {
|
|
90
|
-
expect(() => requireApiKey({})).toThrow('badgr login');
|
|
91
|
-
});
|
|
92
|
-
|
|
93
|
-
it('throws when apiKey is empty string', () => {
|
|
94
|
-
expect(() => requireApiKey({ apiKey: '' })).toThrow();
|
|
95
|
-
});
|
|
96
|
-
});
|