badgr-cli 1.0.0
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/HOW_IT_WORKS.md +245 -0
- package/README.md +147 -0
- package/badgr-cli-1.0.0.tgz +0 -0
- package/package.json +26 -0
- package/src/api.js +120 -0
- package/src/badgr.js +100 -0
- package/src/commands/deploy.js +47 -0
- package/src/commands/down.js +55 -0
- package/src/commands/login.js +20 -0
- package/src/commands/logs.js +49 -0
- package/src/commands/models.js +39 -0
- package/src/commands/receipts.js +82 -0
- package/src/commands/run.js +162 -0
- package/src/commands/serve.js +160 -0
- package/src/commands/shell.js +21 -0
- package/src/commands/status.js +97 -0
- package/src/commands/up.js +134 -0
- package/src/config.js +33 -0
- package/src/router.js +104 -0
- package/src/spec.js +92 -0
- package/src/store.js +88 -0
- package/tests/api.test.js +140 -0
- package/tests/commands.test.js +81 -0
- package/tests/config.test.js +73 -0
- package/tests/router.test.js +157 -0
- package/tests/spec.test.js +143 -0
- package/tests/store.test.js +126 -0
|
@@ -0,0 +1,73 @@
|
|
|
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, DEFAULTS } from '../src/config.js';
|
|
6
|
+
|
|
7
|
+
const tmp = join(tmpdir(), `gpu-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('requireApiKey', () => {
|
|
62
|
+
it('returns the key when present', () => {
|
|
63
|
+
expect(requireApiKey({ apiKey: 'sk-abc' })).toBe('sk-abc');
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('throws an error mentioning `badgr login` when missing', () => {
|
|
67
|
+
expect(() => requireApiKey({})).toThrow('badgr login');
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('throws when apiKey is empty string', () => {
|
|
71
|
+
expect(() => requireApiKey({ apiKey: '' })).toThrow();
|
|
72
|
+
});
|
|
73
|
+
});
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
findById, findByCanonical, findCheapest, listAll,
|
|
4
|
+
getRoutePlan, estimateCost,
|
|
5
|
+
GPU_CATALOG, PROVIDER_CATALOG,
|
|
6
|
+
} from '../src/router.js';
|
|
7
|
+
|
|
8
|
+
describe('findById', () => {
|
|
9
|
+
it('finds GPU by id', () => {
|
|
10
|
+
expect(findById('rtx-4090').name).toContain('4090');
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it('returns null for unknown id', () => {
|
|
14
|
+
expect(findById('rtx-9999')).toBeNull();
|
|
15
|
+
});
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
describe('findByCanonical', () => {
|
|
19
|
+
it('finds GPU by canonical name', () => {
|
|
20
|
+
expect(findByCanonical('RTX_4090').id).toBe('rtx-4090');
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it('returns null for unknown canonical', () => {
|
|
24
|
+
expect(findByCanonical('RTX_9999')).toBeNull();
|
|
25
|
+
});
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
describe('findCheapest', () => {
|
|
29
|
+
it('returns a GPU with no requirements', () => {
|
|
30
|
+
expect(findCheapest()).not.toBeNull();
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('returns the cheapest overall', () => {
|
|
34
|
+
const gpu = findCheapest();
|
|
35
|
+
const allRates = GPU_CATALOG.map(g => g.ratePerHour);
|
|
36
|
+
expect(gpu.ratePerHour).toBe(Math.min(...allRates));
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('filters by minVramGb', () => {
|
|
40
|
+
const gpu = findCheapest({ minVramGb: 40 });
|
|
41
|
+
expect(gpu.vramGb).toBeGreaterThanOrEqual(40);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('filters by tag', () => {
|
|
45
|
+
const gpu = findCheapest({ tag: 'large-model' });
|
|
46
|
+
expect(gpu.tags).toContain('large-model');
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('returns null when no match', () => {
|
|
50
|
+
expect(findCheapest({ minVramGb: 999 })).toBeNull();
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
describe('listAll', () => {
|
|
55
|
+
it('returns all GPUs sorted by rate ascending', () => {
|
|
56
|
+
const gpus = listAll();
|
|
57
|
+
expect(gpus.length).toBe(GPU_CATALOG.length);
|
|
58
|
+
for (let i = 1; i < gpus.length; i++) {
|
|
59
|
+
expect(gpus[i].ratePerHour).toBeGreaterThanOrEqual(gpus[i - 1].ratePerHour);
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('does not mutate the original catalog', () => {
|
|
64
|
+
const before = GPU_CATALOG.map(g => g.ratePerHour);
|
|
65
|
+
listAll();
|
|
66
|
+
expect(GPU_CATALOG.map(g => g.ratePerHour)).toEqual(before);
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
describe('getRoutePlan', () => {
|
|
71
|
+
it('returns lane1 and lane2 for RTX_4090', () => {
|
|
72
|
+
const plan = getRoutePlan('RTX_4090');
|
|
73
|
+
expect(plan.lane1).toBeTruthy();
|
|
74
|
+
expect(plan.lane2.length).toBeGreaterThan(0);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('lane2 is sorted cheapest-first', () => {
|
|
78
|
+
const plan = getRoutePlan('RTX_4090');
|
|
79
|
+
for (let i = 1; i < plan.lane2.length; i++) {
|
|
80
|
+
expect(plan.lane2[i].ratePerHour).toBeGreaterThanOrEqual(plan.lane2[i - 1].ratePerHour);
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('scales ratePerHour by count', () => {
|
|
85
|
+
const plan1 = getRoutePlan('RTX_4090', 1);
|
|
86
|
+
const plan2 = getRoutePlan('RTX_4090', 2);
|
|
87
|
+
expect(plan2.lane2[0].ratePerHour).toBeCloseTo(plan1.lane2[0].ratePerHour * 2, 5);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('cheapestRate matches first provider rate', () => {
|
|
91
|
+
const plan = getRoutePlan('H100');
|
|
92
|
+
expect(plan.cheapestRate).toBe(plan.lane2[0].ratePerHour);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('costWithOverhead > cheapestRate', () => {
|
|
96
|
+
const plan = getRoutePlan('RTX_4090');
|
|
97
|
+
expect(plan.costWithOverhead).toBeGreaterThan(plan.cheapestRate);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('includes gpu info for known canonical', () => {
|
|
101
|
+
const plan = getRoutePlan('H100');
|
|
102
|
+
expect(plan.gpu).not.toBeNull();
|
|
103
|
+
expect(plan.gpu.id).toBe('h100');
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
describe('estimateCost', () => {
|
|
108
|
+
it('calculates cost for 60 minutes = 1 hour', () => {
|
|
109
|
+
expect(estimateCost(1.10, 60)).toBeCloseTo(1.10, 5);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('calculates cost for 30 minutes = half rate', () => {
|
|
113
|
+
expect(estimateCost(2.00, 30)).toBeCloseTo(1.00, 5);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it('returns 0 for 0 minutes', () => {
|
|
117
|
+
expect(estimateCost(3.50, 0)).toBe(0);
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
describe('L40S', () => {
|
|
122
|
+
it('exists in GPU_CATALOG', () => {
|
|
123
|
+
expect(findById('l40s')).not.toBeNull();
|
|
124
|
+
expect(findByCanonical('L40S').vramGb).toBe(48);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it('has provider pricing in PROVIDER_CATALOG', () => {
|
|
128
|
+
expect(Array.isArray(PROVIDER_CATALOG['L40S'])).toBe(true);
|
|
129
|
+
expect(PROVIDER_CATALOG['L40S'].length).toBeGreaterThan(0);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it('route plan is cheapest-first for L40S', () => {
|
|
133
|
+
const plan = getRoutePlan('L40S');
|
|
134
|
+
expect(plan.lane2.length).toBeGreaterThan(0);
|
|
135
|
+
for (let i = 1; i < plan.lane2.length; i++) {
|
|
136
|
+
expect(plan.lane2[i].ratePerHour).toBeGreaterThanOrEqual(plan.lane2[i - 1].ratePerHour);
|
|
137
|
+
}
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
describe('PROVIDER_CATALOG', () => {
|
|
142
|
+
it('exists for all GPU_CATALOG canonical types', () => {
|
|
143
|
+
const catalogCanonicals = [...new Set(GPU_CATALOG.map(g => g.canonical))];
|
|
144
|
+
catalogCanonicals.forEach(c => {
|
|
145
|
+
// Some may not have provider pricing — just check the ones that do are valid arrays
|
|
146
|
+
if (PROVIDER_CATALOG[c]) {
|
|
147
|
+
expect(Array.isArray(PROVIDER_CATALOG[c])).toBe(true);
|
|
148
|
+
PROVIDER_CATALOG[c].forEach(p => {
|
|
149
|
+
expect(p.provider).toBeTruthy();
|
|
150
|
+
expect(p.ratePerHour).toBeGreaterThan(0);
|
|
151
|
+
expect(p.reliability).toBeGreaterThan(0);
|
|
152
|
+
expect(p.reliability).toBeLessThanOrEqual(1);
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
});
|
|
156
|
+
});
|
|
157
|
+
});
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { parseSpec, validateSpec, specLines, parseFlags, normalizeGpuType } from '../src/spec.js';
|
|
3
|
+
|
|
4
|
+
describe('normalizeGpuType', () => {
|
|
5
|
+
it('maps RTX-4090 to RTX_4090', () => expect(normalizeGpuType('RTX-4090')).toBe('RTX_4090'));
|
|
6
|
+
it('maps rtx4090 to RTX_4090', () => expect(normalizeGpuType('rtx4090')).toBe('RTX_4090'));
|
|
7
|
+
it('maps 4090 to RTX_4090', () => expect(normalizeGpuType('4090')).toBe('RTX_4090'));
|
|
8
|
+
it('maps h100 to H100', () => expect(normalizeGpuType('h100')).toBe('H100'));
|
|
9
|
+
it('maps a100 to A100', () => expect(normalizeGpuType('a100')).toBe('A100'));
|
|
10
|
+
it('defaults null to RTX_4090', () => expect(normalizeGpuType(null)).toBe('RTX_4090'));
|
|
11
|
+
it('uppercases unknown types', () => expect(normalizeGpuType('l40s')).toBe('L40S'));
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
describe('parseFlags', () => {
|
|
15
|
+
it('parses --key value pairs', () => {
|
|
16
|
+
const f = parseFlags(['--model', 'llama-3', '--gpu', 'RTX_4090']);
|
|
17
|
+
expect(f.model).toBe('llama-3');
|
|
18
|
+
expect(f.gpu).toBe('RTX_4090');
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it('parses boolean flags', () => {
|
|
22
|
+
const f = parseFlags(['--dry-run']);
|
|
23
|
+
expect(f['dry-run']).toBe(true);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('handles mixed flags and values', () => {
|
|
27
|
+
const f = parseFlags(['--model', 'gpt2', '--dry-run', '--count', '2']);
|
|
28
|
+
expect(f.model).toBe('gpt2');
|
|
29
|
+
expect(f['dry-run']).toBe(true);
|
|
30
|
+
expect(f.count).toBe('2');
|
|
31
|
+
});
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
describe('parseSpec', () => {
|
|
35
|
+
it('defaults to endpoint type with default model', () => {
|
|
36
|
+
const spec = parseSpec([]);
|
|
37
|
+
expect(spec.type).toBe('endpoint');
|
|
38
|
+
expect(spec.model).toBeTruthy();
|
|
39
|
+
expect(spec.count).toBe(1);
|
|
40
|
+
expect(spec.region).toBe('US');
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('infers job type from --image', () => {
|
|
44
|
+
const spec = parseSpec(['--image', 'vllm/vllm-openai:latest']);
|
|
45
|
+
expect(spec.type).toBe('job');
|
|
46
|
+
expect(spec.image).toBe('vllm/vllm-openai:latest');
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('respects explicit --type', () => {
|
|
50
|
+
const spec = parseSpec(['--type', 'job', '--image', 'myimg:latest']);
|
|
51
|
+
expect(spec.type).toBe('job');
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('parses all flags', () => {
|
|
55
|
+
const spec = parseSpec([
|
|
56
|
+
'--model', 'llama-3',
|
|
57
|
+
'--gpu', 'H100',
|
|
58
|
+
'--count', '2',
|
|
59
|
+
'--region', 'EU',
|
|
60
|
+
'--max-price', '3.00',
|
|
61
|
+
'--name', 'my-deploy',
|
|
62
|
+
]);
|
|
63
|
+
expect(spec.model).toBe('llama-3');
|
|
64
|
+
expect(spec.gpu).toBe('H100');
|
|
65
|
+
expect(spec.count).toBe(2);
|
|
66
|
+
expect(spec.region).toBe('EU');
|
|
67
|
+
expect(spec.maxPrice).toBe(3.00);
|
|
68
|
+
expect(spec.name).toBe('my-deploy');
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('sets dryRun when --dry-run present', () => {
|
|
72
|
+
const spec = parseSpec(['--dry-run']);
|
|
73
|
+
expect(spec.dryRun).toBe(true);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('normalizes GPU type', () => {
|
|
77
|
+
const spec = parseSpec(['--gpu', 'rtx-4090']);
|
|
78
|
+
expect(spec.gpu).toBe('RTX_4090');
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('--endpoint shorthand sets type to endpoint', () => {
|
|
82
|
+
const spec = parseSpec(['--endpoint', '--model', 'X']);
|
|
83
|
+
expect(spec.type).toBe('endpoint');
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('--job shorthand sets type to job', () => {
|
|
87
|
+
const spec = parseSpec(['--job', '--image', 'myimg:latest']);
|
|
88
|
+
expect(spec.type).toBe('job');
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('--type takes precedence over shorthands', () => {
|
|
92
|
+
const spec = parseSpec(['--type', 'job', '--endpoint']);
|
|
93
|
+
expect(spec.type).toBe('job');
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('parses L40S gpu', () => {
|
|
97
|
+
const spec = parseSpec(['--endpoint', '--model', 'X', '--gpu', 'L40S']);
|
|
98
|
+
expect(spec.gpu).toBe('L40S');
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
describe('validateSpec', () => {
|
|
103
|
+
it('returns no errors for valid endpoint spec', () => {
|
|
104
|
+
const spec = parseSpec(['--model', 'llama-3', '--gpu', 'RTX_4090']);
|
|
105
|
+
expect(validateSpec(spec)).toEqual([]);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('returns error for invalid type', () => {
|
|
109
|
+
const spec = { ...parseSpec([]), type: 'invalid' };
|
|
110
|
+
const errors = validateSpec(spec);
|
|
111
|
+
expect(errors.some(e => e.includes('type'))).toBe(true);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it('returns error for count out of range', () => {
|
|
115
|
+
const spec = { ...parseSpec([]), count: 0 };
|
|
116
|
+
expect(validateSpec(spec).some(e => e.includes('count'))).toBe(true);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('returns error for invalid region', () => {
|
|
120
|
+
const spec = { ...parseSpec([]), region: 'MARS' };
|
|
121
|
+
expect(validateSpec(spec).some(e => e.includes('region'))).toBe(true);
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
describe('specLines', () => {
|
|
126
|
+
it('includes type and gpu', () => {
|
|
127
|
+
const spec = parseSpec(['--model', 'llama-3', '--gpu', 'RTX_4090']);
|
|
128
|
+
const lines = specLines(spec);
|
|
129
|
+
expect(lines.some(l => l.includes('endpoint'))).toBe(true);
|
|
130
|
+
expect(lines.some(l => l.includes('RTX_4090'))).toBe(true);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it('includes model when present', () => {
|
|
134
|
+
const spec = parseSpec(['--model', 'llama-3']);
|
|
135
|
+
expect(specLines(spec).some(l => l.includes('llama-3'))).toBe(true);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it('omits null fields', () => {
|
|
139
|
+
const spec = { ...parseSpec([]), maxPrice: null, name: null };
|
|
140
|
+
const lines = specLines(spec);
|
|
141
|
+
expect(lines.every(l => l !== null)).toBe(true);
|
|
142
|
+
});
|
|
143
|
+
});
|
|
@@ -0,0 +1,126 @@
|
|
|
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, listReceipts,
|
|
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
|
+
});
|