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/connect.test.js
DELETED
|
@@ -1,83 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
2
|
-
|
|
3
|
-
const store = {};
|
|
4
|
-
vi.mock('../src/credentials.js', () => ({
|
|
5
|
-
KNOWN_PROVIDERS: ['anthropic', 'openai'],
|
|
6
|
-
getCredential: vi.fn(provider => store[provider] ?? null),
|
|
7
|
-
setCredential: vi.fn((provider, value) => { store[provider] = value; }),
|
|
8
|
-
removeCredential: vi.fn(provider => {
|
|
9
|
-
if (!(provider in store)) return false;
|
|
10
|
-
delete store[provider];
|
|
11
|
-
return true;
|
|
12
|
-
}),
|
|
13
|
-
listCredentials: vi.fn(() => Object.keys(store)),
|
|
14
|
-
CREDENTIALS_FILE: '/home/test/.badgr/credentials.json',
|
|
15
|
-
}));
|
|
16
|
-
|
|
17
|
-
const { connectCommand } = await import('../src/commands/connect.js');
|
|
18
|
-
|
|
19
|
-
const chalk = { red: s => s, dim: s => s, green: s => s, bold: s => s, yellow: s => s, cyan: s => s };
|
|
20
|
-
|
|
21
|
-
describe('connectCommand', () => {
|
|
22
|
-
beforeEach(() => {
|
|
23
|
-
process.exitCode = undefined;
|
|
24
|
-
vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
25
|
-
vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
26
|
-
for (const k of Object.keys(store)) delete store[k];
|
|
27
|
-
});
|
|
28
|
-
|
|
29
|
-
afterEach(() => {
|
|
30
|
-
vi.restoreAllMocks();
|
|
31
|
-
process.exitCode = undefined;
|
|
32
|
-
});
|
|
33
|
-
|
|
34
|
-
it('lists nothing connected when the store is empty', async () => {
|
|
35
|
-
await connectCommand([], chalk);
|
|
36
|
-
const logged = console.log.mock.calls.map(c => c.join(' ')).join('\n');
|
|
37
|
-
expect(logged).toContain('No providers connected');
|
|
38
|
-
});
|
|
39
|
-
|
|
40
|
-
it('rejects an unknown provider', async () => {
|
|
41
|
-
await connectCommand(['aws'], chalk);
|
|
42
|
-
expect(process.exitCode).toBe(1);
|
|
43
|
-
});
|
|
44
|
-
|
|
45
|
-
it('stores a credential with --key non-interactively', async () => {
|
|
46
|
-
await connectCommand(['anthropic', '--key', 'sk-ant-fake'], chalk);
|
|
47
|
-
expect(process.exitCode).toBeUndefined();
|
|
48
|
-
expect(store.anthropic).toBe('sk-ant-fake');
|
|
49
|
-
const logged = console.log.mock.calls.map(c => c.join(' ')).join('\n');
|
|
50
|
-
expect(logged).toContain('Connected anthropic');
|
|
51
|
-
});
|
|
52
|
-
|
|
53
|
-
it('lists a connected provider', async () => {
|
|
54
|
-
await connectCommand(['anthropic', '--key', 'sk-ant-fake'], chalk);
|
|
55
|
-
await connectCommand([], chalk);
|
|
56
|
-
const logged = console.log.mock.calls.map(c => c.join(' ')).join('\n');
|
|
57
|
-
expect(logged).toContain('anthropic');
|
|
58
|
-
});
|
|
59
|
-
|
|
60
|
-
it('removes a stored credential with --remove', async () => {
|
|
61
|
-
await connectCommand(['anthropic', '--key', 'sk-ant-fake'], chalk);
|
|
62
|
-
await connectCommand(['anthropic', '--remove'], chalk);
|
|
63
|
-
expect(store.anthropic).toBeUndefined();
|
|
64
|
-
});
|
|
65
|
-
|
|
66
|
-
it('`badgr connect list` lists connected providers instead of erroring as an unknown provider', async () => {
|
|
67
|
-
await connectCommand(['anthropic', '--key', 'sk-ant-fake'], chalk);
|
|
68
|
-
await connectCommand(['list'], chalk);
|
|
69
|
-
expect(process.exitCode).toBeUndefined();
|
|
70
|
-
const logged = console.log.mock.calls.map(c => c.join(' ')).join('\n');
|
|
71
|
-
expect(logged).toContain('anthropic');
|
|
72
|
-
});
|
|
73
|
-
|
|
74
|
-
it('requires --key when not running in an interactive TTY', async () => {
|
|
75
|
-
await connectCommand(['anthropic'], chalk);
|
|
76
|
-
expect(process.exitCode).toBe(1);
|
|
77
|
-
});
|
|
78
|
-
|
|
79
|
-
it('rejects an empty --key', async () => {
|
|
80
|
-
await connectCommand(['anthropic', '--key', ' '], chalk);
|
|
81
|
-
expect(process.exitCode).toBe(1);
|
|
82
|
-
});
|
|
83
|
-
});
|
package/tests/detect.test.js
DELETED
|
@@ -1,191 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
2
|
-
import fs from 'fs';
|
|
3
|
-
import os from 'os';
|
|
4
|
-
import path from 'path';
|
|
5
|
-
import { detectWorkload, workloadTypeLabel } from '../src/detect.js';
|
|
6
|
-
|
|
7
|
-
let tmpRoot;
|
|
8
|
-
|
|
9
|
-
function makeProject(files) {
|
|
10
|
-
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'badgr-detect-'));
|
|
11
|
-
for (const [rel, content] of Object.entries(files)) {
|
|
12
|
-
const abs = path.join(dir, rel);
|
|
13
|
-
fs.mkdirSync(path.dirname(abs), { recursive: true });
|
|
14
|
-
fs.writeFileSync(abs, content);
|
|
15
|
-
}
|
|
16
|
-
return dir;
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
beforeEach(() => { tmpRoot = []; });
|
|
20
|
-
afterEach(() => {
|
|
21
|
-
for (const dir of tmpRoot) {
|
|
22
|
-
fs.rmSync(dir, { recursive: true, force: true });
|
|
23
|
-
}
|
|
24
|
-
});
|
|
25
|
-
|
|
26
|
-
function project(files) {
|
|
27
|
-
const dir = makeProject(files);
|
|
28
|
-
tmpRoot.push(dir);
|
|
29
|
-
return dir;
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
describe('detectWorkload — missing / empty directory', () => {
|
|
33
|
-
it('reports an error without throwing for a missing directory', () => {
|
|
34
|
-
const report = detectWorkload('/nonexistent/badgr-detect-fixture');
|
|
35
|
-
expect(report.error).toBeTruthy();
|
|
36
|
-
expect(report.confidence).toBe('low');
|
|
37
|
-
});
|
|
38
|
-
|
|
39
|
-
it('is low confidence with no command for an empty directory', () => {
|
|
40
|
-
const dir = project({});
|
|
41
|
-
const report = detectWorkload(dir);
|
|
42
|
-
expect(report.command).toBeNull();
|
|
43
|
-
expect(report.confidence).toBe('low');
|
|
44
|
-
});
|
|
45
|
-
});
|
|
46
|
-
|
|
47
|
-
describe('detectWorkload — plain Python CUDA script', () => {
|
|
48
|
-
it('finds the entry point and requirements, classifies as custom', () => {
|
|
49
|
-
const dir = project({
|
|
50
|
-
'train.py': `
|
|
51
|
-
import torch
|
|
52
|
-
model = torch.nn.Linear(10, 10).cuda()
|
|
53
|
-
for step in range(100):
|
|
54
|
-
optimizer.step()
|
|
55
|
-
`,
|
|
56
|
-
'requirements.txt': 'torch\nnumpy\n',
|
|
57
|
-
'output/.gitkeep': '',
|
|
58
|
-
});
|
|
59
|
-
const report = detectWorkload(dir);
|
|
60
|
-
expect(report.command).toBe('python train.py');
|
|
61
|
-
expect(report.requirements).toContain('requirements.txt');
|
|
62
|
-
expect(report.outputs).toContain('output/');
|
|
63
|
-
expect(report.workloadType).toBe('training');
|
|
64
|
-
expect(['medium', 'high']).toContain(report.confidence);
|
|
65
|
-
});
|
|
66
|
-
});
|
|
67
|
-
|
|
68
|
-
describe('detectWorkload — vLLM endpoint', () => {
|
|
69
|
-
it('classifies as endpoint and detects the port', () => {
|
|
70
|
-
const dir = project({
|
|
71
|
-
'server.py': `
|
|
72
|
-
from vllm import LLM
|
|
73
|
-
import uvicorn
|
|
74
|
-
app = None
|
|
75
|
-
|
|
76
|
-
if __name__ == "__main__":
|
|
77
|
-
uvicorn.run(app, port=8000)
|
|
78
|
-
`,
|
|
79
|
-
'requirements.txt': 'vllm\nfastapi\nuvicorn\n',
|
|
80
|
-
});
|
|
81
|
-
const report = detectWorkload(dir);
|
|
82
|
-
expect(report.command).toBe('python server.py');
|
|
83
|
-
expect(report.workloadType).toBe('endpoint');
|
|
84
|
-
expect(report.ports).toContain(8000);
|
|
85
|
-
expect(report.healthPath).toBe('/health');
|
|
86
|
-
});
|
|
87
|
-
});
|
|
88
|
-
|
|
89
|
-
describe('detectWorkload — LoRA training repo', () => {
|
|
90
|
-
it('classifies as training and estimates high VRAM for a large model', () => {
|
|
91
|
-
const dir = project({
|
|
92
|
-
'train.py': `
|
|
93
|
-
from peft import LoraConfig
|
|
94
|
-
base_model = "meta-llama/Llama-3.1-70B-Instruct"
|
|
95
|
-
trainer = Trainer(model=model)
|
|
96
|
-
trainer.train()
|
|
97
|
-
`,
|
|
98
|
-
'config.yaml': 'base_model: meta-llama/Llama-3.1-70B-Instruct\nlora_r: 16\n',
|
|
99
|
-
'requirements.txt': 'peft\ntransformers\naccelerate\n',
|
|
100
|
-
'checkpoints/.gitkeep': '',
|
|
101
|
-
});
|
|
102
|
-
const report = detectWorkload(dir);
|
|
103
|
-
expect(report.workloadType).toBe('training');
|
|
104
|
-
expect(report.checkpoints).toContain('checkpoints/');
|
|
105
|
-
expect(report.models.some(m => m.includes('Llama-3.1-70B'))).toBe(true);
|
|
106
|
-
expect(report.vram).toBe('80+ GB');
|
|
107
|
-
});
|
|
108
|
-
});
|
|
109
|
-
|
|
110
|
-
describe('detectWorkload — ComfyUI-style project', () => {
|
|
111
|
-
it('classifies as image_gen from a workflow json without special-casing ComfyUI by name', () => {
|
|
112
|
-
const dir = project({
|
|
113
|
-
'workflow_api.json': JSON.stringify({ '1': { class_type: 'CheckpointLoaderSimple' } }),
|
|
114
|
-
'requirements.txt': 'torch\npillow\n',
|
|
115
|
-
});
|
|
116
|
-
const report = detectWorkload(dir);
|
|
117
|
-
expect(report.workloadType).toBe('image_gen');
|
|
118
|
-
expect(workloadTypeLabel(report.workloadType)).toMatch(/image generation/);
|
|
119
|
-
});
|
|
120
|
-
});
|
|
121
|
-
|
|
122
|
-
describe('detectWorkload — custom Docker image', () => {
|
|
123
|
-
it('detects the Dockerfile and exposed port instead of guessing a base image', () => {
|
|
124
|
-
const dir = project({
|
|
125
|
-
Dockerfile: `
|
|
126
|
-
FROM python:3.11-slim
|
|
127
|
-
COPY . /app
|
|
128
|
-
EXPOSE 7860
|
|
129
|
-
CMD ["python", "app.py"]
|
|
130
|
-
`,
|
|
131
|
-
'app.py': `
|
|
132
|
-
import gradio as gr
|
|
133
|
-
demo = gr.Interface()
|
|
134
|
-
demo.launch(server_port=7860)
|
|
135
|
-
`,
|
|
136
|
-
});
|
|
137
|
-
const report = detectWorkload(dir);
|
|
138
|
-
expect(report.dockerfile).toBe('Dockerfile');
|
|
139
|
-
expect(report.image).toBeNull();
|
|
140
|
-
expect(report.ports).toContain(7860);
|
|
141
|
-
expect(report.workloadType).toBe('endpoint');
|
|
142
|
-
});
|
|
143
|
-
});
|
|
144
|
-
|
|
145
|
-
describe('detectWorkload — RunPod-style serverless handler job', () => {
|
|
146
|
-
it('classifies a handler-based job as an endpoint without naming any provider', () => {
|
|
147
|
-
const dir = project({
|
|
148
|
-
'rp_handler.py': `
|
|
149
|
-
import runpod
|
|
150
|
-
|
|
151
|
-
def handler(event):
|
|
152
|
-
return {"output": "ok"}
|
|
153
|
-
|
|
154
|
-
runpod.serverless.start({"handler": handler})
|
|
155
|
-
`,
|
|
156
|
-
'requirements.txt': 'runpod\n',
|
|
157
|
-
});
|
|
158
|
-
const report = detectWorkload(dir);
|
|
159
|
-
expect(report.command).toBe('python rp_handler.py');
|
|
160
|
-
expect(report.workloadType).toBe('endpoint');
|
|
161
|
-
// No provider name should leak into any user-visible field.
|
|
162
|
-
const serialized = JSON.stringify(report).toLowerCase();
|
|
163
|
-
expect(serialized).not.toContain('runpod.com');
|
|
164
|
-
});
|
|
165
|
-
});
|
|
166
|
-
|
|
167
|
-
describe('detectWorkload — batch image generation over an input directory', () => {
|
|
168
|
-
it('classifies as batch when the script iterates an input directory', () => {
|
|
169
|
-
const dir = project({
|
|
170
|
-
'generate.py': `
|
|
171
|
-
import os
|
|
172
|
-
for fname in os.listdir("input"):
|
|
173
|
-
process(fname)
|
|
174
|
-
`,
|
|
175
|
-
'input/.gitkeep': '',
|
|
176
|
-
'output/.gitkeep': '',
|
|
177
|
-
});
|
|
178
|
-
const report = detectWorkload(dir);
|
|
179
|
-
expect(report.workloadType).toBe('batch');
|
|
180
|
-
expect(report.inputs).toContain('input/');
|
|
181
|
-
expect(report.outputs).toContain('output/');
|
|
182
|
-
});
|
|
183
|
-
});
|
|
184
|
-
|
|
185
|
-
describe('workloadTypeLabel', () => {
|
|
186
|
-
it('never returns a provider-specific label', () => {
|
|
187
|
-
for (const t of ['endpoint', 'training', 'image_gen', 'batch', 'custom', 'unknown']) {
|
|
188
|
-
expect(workloadTypeLabel(t)).not.toMatch(/runpod|vast|hyperstack/i);
|
|
189
|
-
}
|
|
190
|
-
});
|
|
191
|
-
});
|
package/tests/down.test.js
DELETED
|
@@ -1,150 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* badgr down — receipt display and runtime formatting tests
|
|
3
|
-
*/
|
|
4
|
-
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
5
|
-
import { downCommand } from '../src/commands/down.js';
|
|
6
|
-
|
|
7
|
-
vi.mock('../src/api.js', () => ({
|
|
8
|
-
terminateDeployment: vi.fn(),
|
|
9
|
-
listDeployments: vi.fn(),
|
|
10
|
-
}));
|
|
11
|
-
|
|
12
|
-
vi.mock('../src/store.js', () => ({
|
|
13
|
-
findDeployment: vi.fn(() => null),
|
|
14
|
-
removeDeployment: vi.fn(),
|
|
15
|
-
addReceipt: vi.fn(),
|
|
16
|
-
generateReceiptId: vi.fn(() => 'rcpt-test-001'),
|
|
17
|
-
}));
|
|
18
|
-
|
|
19
|
-
vi.mock('../src/config.js', () => ({
|
|
20
|
-
requireApiKey: vi.fn(),
|
|
21
|
-
}));
|
|
22
|
-
|
|
23
|
-
import * as api from '../src/api.js';
|
|
24
|
-
import * as store from '../src/store.js';
|
|
25
|
-
|
|
26
|
-
const config = { apiKey: 'sk-test', baseUrl: 'https://api.test/v1' };
|
|
27
|
-
|
|
28
|
-
const chalk = {
|
|
29
|
-
bold: s => s,
|
|
30
|
-
dim: s => s,
|
|
31
|
-
red: s => s,
|
|
32
|
-
yellow: s => s,
|
|
33
|
-
green: s => s,
|
|
34
|
-
cyan: s => s,
|
|
35
|
-
};
|
|
36
|
-
|
|
37
|
-
function makeStoppedDep(runtimeSeconds, costPerHour = 1.00, overrides = {}) {
|
|
38
|
-
const now = Date.now() / 1000;
|
|
39
|
-
return {
|
|
40
|
-
deployment_id: 'dep-test-001',
|
|
41
|
-
gpu_type: 'L40S',
|
|
42
|
-
cost_per_hour: costPerHour,
|
|
43
|
-
stopped_at: now,
|
|
44
|
-
started_at: now - runtimeSeconds,
|
|
45
|
-
teardown_ok: 'ok',
|
|
46
|
-
...overrides,
|
|
47
|
-
};
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
beforeEach(() => {
|
|
51
|
-
vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
52
|
-
vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
53
|
-
vi.spyOn(process.stdout, 'write').mockImplementation(() => true);
|
|
54
|
-
vi.resetAllMocks();
|
|
55
|
-
store.findDeployment.mockReturnValue(null);
|
|
56
|
-
store.generateReceiptId.mockReturnValue('rcpt-test-001');
|
|
57
|
-
});
|
|
58
|
-
|
|
59
|
-
afterEach(() => {
|
|
60
|
-
vi.restoreAllMocks();
|
|
61
|
-
});
|
|
62
|
-
|
|
63
|
-
describe('runtime display formatting', () => {
|
|
64
|
-
it('shows minutes only for short runs (< 1h)', async () => {
|
|
65
|
-
api.terminateDeployment.mockResolvedValue(makeStoppedDep(27 * 60, 0.34));
|
|
66
|
-
const lines = [];
|
|
67
|
-
console.log.mockImplementation((...args) => lines.push(args.join(' ')));
|
|
68
|
-
|
|
69
|
-
await downCommand(config, ['dep-test-001'], chalk);
|
|
70
|
-
|
|
71
|
-
const runtimeLine = lines.find(l => l.includes('Runtime:'));
|
|
72
|
-
expect(runtimeLine).toMatch(/27m/);
|
|
73
|
-
expect(runtimeLine).not.toMatch(/\dh/);
|
|
74
|
-
});
|
|
75
|
-
|
|
76
|
-
it('shows hours and minutes for runs >= 1h', async () => {
|
|
77
|
-
api.terminateDeployment.mockResolvedValue(makeStoppedDep(4091 * 60, 1.00));
|
|
78
|
-
const lines = [];
|
|
79
|
-
console.log.mockImplementation((...args) => lines.push(args.join(' ')));
|
|
80
|
-
|
|
81
|
-
await downCommand(config, ['dep-test-001'], chalk);
|
|
82
|
-
|
|
83
|
-
const runtimeLine = lines.find(l => l.includes('Runtime:'));
|
|
84
|
-
expect(runtimeLine).toMatch(/2d 20h 11m/);
|
|
85
|
-
expect(runtimeLine).not.toMatch(/4091m/);
|
|
86
|
-
});
|
|
87
|
-
|
|
88
|
-
it('shows days, hours, minutes for multi-day runs', async () => {
|
|
89
|
-
api.terminateDeployment.mockResolvedValue(makeStoppedDep(5722 * 60, 0.46));
|
|
90
|
-
const lines = [];
|
|
91
|
-
console.log.mockImplementation((...args) => lines.push(args.join(' ')));
|
|
92
|
-
|
|
93
|
-
await downCommand(config, ['dep-test-001'], chalk);
|
|
94
|
-
|
|
95
|
-
const runtimeLine = lines.find(l => l.includes('Runtime:'));
|
|
96
|
-
expect(runtimeLine).toMatch(/3d 23h 22m/);
|
|
97
|
-
expect(runtimeLine).not.toMatch(/5722m/);
|
|
98
|
-
});
|
|
99
|
-
});
|
|
100
|
-
|
|
101
|
-
describe('receipt is recorded', () => {
|
|
102
|
-
it('adds a receipt with correct fields on stop', async () => {
|
|
103
|
-
api.terminateDeployment.mockResolvedValue(makeStoppedDep(27 * 60, 0.34));
|
|
104
|
-
|
|
105
|
-
await downCommand(config, ['dep-test-001'], chalk);
|
|
106
|
-
|
|
107
|
-
expect(store.addReceipt).toHaveBeenCalledWith(expect.objectContaining({
|
|
108
|
-
receiptId: 'rcpt-test-001',
|
|
109
|
-
deploymentId: 'dep-test-001',
|
|
110
|
-
gpu: 'L40S',
|
|
111
|
-
status: 'terminated',
|
|
112
|
-
}));
|
|
113
|
-
const call = store.addReceipt.mock.calls[0][0];
|
|
114
|
-
expect(call.runtimeSeconds).toBeCloseTo(27 * 60, -1);
|
|
115
|
-
expect(call.finalCost).toBeGreaterThan(0);
|
|
116
|
-
});
|
|
117
|
-
});
|
|
118
|
-
|
|
119
|
-
describe('teardown not confirmed', () => {
|
|
120
|
-
it('reports unconfirmed status and never claims "Stopped" when the backend did not confirm teardown', async () => {
|
|
121
|
-
// A 200 response with teardown_ok: 'failed' — the DELETE was accepted
|
|
122
|
-
// but the backend could not confirm the provider resource is gone.
|
|
123
|
-
api.terminateDeployment.mockResolvedValue(makeStoppedDep(27 * 60, 0.34, { teardown_ok: 'failed' }));
|
|
124
|
-
const logLines = [];
|
|
125
|
-
vi.spyOn(console, 'log').mockImplementation(msg => logLines.push(msg ?? ''));
|
|
126
|
-
|
|
127
|
-
await downCommand(config, ['dep-test-001'], chalk);
|
|
128
|
-
|
|
129
|
-
const out = logLines.join('\n');
|
|
130
|
-
expect(out).not.toContain('✓ Stopped');
|
|
131
|
-
expect(out).toContain('not confirmed');
|
|
132
|
-
expect(store.addReceipt).toHaveBeenCalledWith(expect.objectContaining({
|
|
133
|
-
status: 'teardown_unconfirmed',
|
|
134
|
-
}));
|
|
135
|
-
console.log.mockRestore();
|
|
136
|
-
});
|
|
137
|
-
});
|
|
138
|
-
|
|
139
|
-
describe('error handling', () => {
|
|
140
|
-
it('prints error and returns when terminateDeployment throws', async () => {
|
|
141
|
-
api.terminateDeployment.mockRejectedValue(new Error('network error'));
|
|
142
|
-
const errLines = [];
|
|
143
|
-
console.error.mockImplementation(msg => errLines.push(msg));
|
|
144
|
-
|
|
145
|
-
await downCommand(config, ['dep-test-001'], chalk);
|
|
146
|
-
|
|
147
|
-
expect(errLines.join('\n')).toMatch(/network error|Could not stop/i);
|
|
148
|
-
expect(store.addReceipt).not.toHaveBeenCalled();
|
|
149
|
-
});
|
|
150
|
-
});
|
package/tests/errors.test.js
DELETED
|
@@ -1,130 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Tests for the unified error catalog (errors.js).
|
|
3
|
-
* Verifies that every catalog entry produces output matching the four CLI guarantees:
|
|
4
|
-
* 1. What happened (code + message in output)
|
|
5
|
-
* 2. Retry status (retried note when entry.retried === true)
|
|
6
|
-
* 3. Billing status (billing label in output)
|
|
7
|
-
* 4. Next step (at least one → hint in output)
|
|
8
|
-
*/
|
|
9
|
-
import { describe, it, expect } from 'vitest';
|
|
10
|
-
import { CATALOG, formatCliError } from '../src/errors.js';
|
|
11
|
-
|
|
12
|
-
// Passthrough chalk mock — identical to what the lifecycle test suite uses.
|
|
13
|
-
const chalk = new Proxy({}, {
|
|
14
|
-
get: () => (s) => s,
|
|
15
|
-
});
|
|
16
|
-
|
|
17
|
-
describe('CATALOG completeness', () => {
|
|
18
|
-
const REQUIRED_FIELDS = ['message', 'billing', 'retried', 'hint', 'severity'];
|
|
19
|
-
const VALID_BILLING = ['never_started', 'stopped', 'check_receipt', 'charged'];
|
|
20
|
-
const VALID_SEVERITY = ['P1', 'P2', 'P3', 'P4'];
|
|
21
|
-
|
|
22
|
-
for (const [code, entry] of Object.entries(CATALOG)) {
|
|
23
|
-
it(`${code} has all required fields`, () => {
|
|
24
|
-
for (const f of REQUIRED_FIELDS) {
|
|
25
|
-
expect(entry, `${code} missing field: ${f}`).toHaveProperty(f);
|
|
26
|
-
}
|
|
27
|
-
expect(VALID_BILLING, `${code}.billing invalid`).toContain(entry.billing);
|
|
28
|
-
expect(VALID_SEVERITY, `${code}.severity invalid`).toContain(entry.severity);
|
|
29
|
-
});
|
|
30
|
-
}
|
|
31
|
-
});
|
|
32
|
-
|
|
33
|
-
describe('formatCliError', () => {
|
|
34
|
-
it('includes the error code in the output', () => {
|
|
35
|
-
const out = formatCliError('NO_CAPACITY', {}, chalk);
|
|
36
|
-
expect(out).toContain('NO_CAPACITY');
|
|
37
|
-
});
|
|
38
|
-
|
|
39
|
-
it('includes billing label in output', () => {
|
|
40
|
-
const out = formatCliError('NO_CAPACITY', {}, chalk);
|
|
41
|
-
expect(out).toContain('Billing: never started');
|
|
42
|
-
});
|
|
43
|
-
|
|
44
|
-
it('includes a → hint line', () => {
|
|
45
|
-
const out = formatCliError('NO_CAPACITY', {}, chalk);
|
|
46
|
-
expect(out).toContain('→');
|
|
47
|
-
});
|
|
48
|
-
|
|
49
|
-
it('shows "Badgr retried automatically" only for retried=true entries', () => {
|
|
50
|
-
const retried = formatCliError('PROVISIONING_FAILED', {}, chalk);
|
|
51
|
-
expect(retried).toContain('Badgr retried automatically');
|
|
52
|
-
|
|
53
|
-
const notRetried = formatCliError('NO_CAPACITY', {}, chalk);
|
|
54
|
-
expect(notRetried).not.toContain('Badgr retried automatically');
|
|
55
|
-
});
|
|
56
|
-
|
|
57
|
-
it('does not expose internal fields by default', () => {
|
|
58
|
-
const out = formatCliError('PROVISIONING_FAILED', {}, chalk, { detail: 'SECRET_PROVIDER_URL' });
|
|
59
|
-
expect(out).not.toContain('SECRET_PROVIDER_URL');
|
|
60
|
-
});
|
|
61
|
-
|
|
62
|
-
it('exposes internal fields when BADGR_DEBUG=1', () => {
|
|
63
|
-
process.env.BADGR_DEBUG = '1';
|
|
64
|
-
try {
|
|
65
|
-
const out = formatCliError('PROVISIONING_FAILED', {}, chalk, { detail: 'SECRET_PROVIDER_URL' });
|
|
66
|
-
expect(out).toContain('SECRET_PROVIDER_URL');
|
|
67
|
-
} finally {
|
|
68
|
-
delete process.env.BADGR_DEBUG;
|
|
69
|
-
}
|
|
70
|
-
});
|
|
71
|
-
|
|
72
|
-
it('falls back gracefully for unknown codes', () => {
|
|
73
|
-
const out = formatCliError('TOTALLY_UNKNOWN_CODE', {}, chalk);
|
|
74
|
-
expect(out).toContain('TOTALLY_UNKNOWN_CODE');
|
|
75
|
-
expect(out).toContain('unexpected error');
|
|
76
|
-
});
|
|
77
|
-
|
|
78
|
-
// ── Specific catalog entries ──────────────────────────────────────────────
|
|
79
|
-
|
|
80
|
-
it('PROVISIONING_FAILED compat_failure contains incompatib and GPU type/image', () => {
|
|
81
|
-
const out = formatCliError('PROVISIONING_FAILED', { failure_category: 'compat_failure' }, chalk);
|
|
82
|
-
expect(out).toContain('incompatib');
|
|
83
|
-
expect(out).toMatch(/GPU type|image/i);
|
|
84
|
-
});
|
|
85
|
-
|
|
86
|
-
it('PROVISIONING_FAILED generic contains "failed to start" and "try again"', () => {
|
|
87
|
-
const out = formatCliError('PROVISIONING_FAILED', {}, chalk);
|
|
88
|
-
expect(out).toContain('failed to start');
|
|
89
|
-
expect(out).toMatch(/try again/i);
|
|
90
|
-
expect(out).not.toContain('incompatib');
|
|
91
|
-
});
|
|
92
|
-
|
|
93
|
-
it('NO_CAPACITY output contains "capacity" (satisfies /capacity/i test)', () => {
|
|
94
|
-
const out = formatCliError('NO_CAPACITY', {}, chalk);
|
|
95
|
-
expect(out).toMatch(/capacity/i);
|
|
96
|
-
});
|
|
97
|
-
|
|
98
|
-
it('TEARDOWN_FAILED is P1 severity', () => {
|
|
99
|
-
expect(CATALOG.TEARDOWN_FAILED.severity).toBe('P1');
|
|
100
|
-
});
|
|
101
|
-
|
|
102
|
-
it('TEARDOWN_FAILED billing is check_receipt', () => {
|
|
103
|
-
expect(CATALOG.TEARDOWN_FAILED.billing).toBe('check_receipt');
|
|
104
|
-
});
|
|
105
|
-
|
|
106
|
-
it('TEARDOWN_FAILED output contains billing warning', () => {
|
|
107
|
-
const out = formatCliError('TEARDOWN_FAILED', { deploymentId: 'dep-123', receiptId: 'rcpt-456' }, chalk);
|
|
108
|
-
expect(out).toContain('check your receipt');
|
|
109
|
-
expect(out).toContain('dep-123');
|
|
110
|
-
});
|
|
111
|
-
|
|
112
|
-
it('JOB_INFRASTRUCTURE_FAILURE billing is never_started', () => {
|
|
113
|
-
expect(CATALOG.JOB_INFRASTRUCTURE_FAILURE.billing).toBe('never_started');
|
|
114
|
-
});
|
|
115
|
-
|
|
116
|
-
it('HEALTH_CHECK_FAILED billing is stopped', () => {
|
|
117
|
-
expect(CATALOG.HEALTH_CHECK_FAILED.billing).toBe('stopped');
|
|
118
|
-
});
|
|
119
|
-
|
|
120
|
-
it('BILLING_INSUFFICIENT shows balance and required when provided', () => {
|
|
121
|
-
const out = formatCliError('BILLING_INSUFFICIENT', {
|
|
122
|
-
balance_usd: 1.23,
|
|
123
|
-
required_usd: 5.00,
|
|
124
|
-
topup_url: 'https://example.com/billing',
|
|
125
|
-
}, chalk);
|
|
126
|
-
expect(out).toContain('1.23');
|
|
127
|
-
expect(out).toContain('5.00');
|
|
128
|
-
expect(out).toContain('example.com/billing');
|
|
129
|
-
});
|
|
130
|
-
});
|
|
@@ -1,41 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Regression test for a real production incident: the client-side /run
|
|
3
|
-
* timeout (previously 130s) fired before the server's own routing/
|
|
4
|
-
* provisioning deadline could, so the CLI reported "failed to submit" for
|
|
5
|
-
* jobs that had actually been created and were already billing —
|
|
6
|
-
* Future.result(timeout=...) on the backend never cancels the in-flight
|
|
7
|
-
* work, so aborting client-side doesn't stop the server from finishing.
|
|
8
|
-
*
|
|
9
|
-
* The invariant that matters: this client timeout must stay comfortably
|
|
10
|
-
* above backend/run_serve_routes.py's _PROVISION_TIMEOUT_SEC (200s default,
|
|
11
|
-
* itself kept above deployment_service.py's 180s routing-search deadline —
|
|
12
|
-
* see the comments at each of those three call sites, which all reference
|
|
13
|
-
* this same chain). This test pins the client-side half of that chain so a
|
|
14
|
-
* future edit can't silently drop it back below the server's deadline.
|
|
15
|
-
*/
|
|
16
|
-
import { describe, it, expect, vi } from 'vitest';
|
|
17
|
-
|
|
18
|
-
const mockCallApi = vi.fn().mockResolvedValue({ deployment_id: 'dep-1', cost_per_hour: 0.1 });
|
|
19
|
-
vi.mock('../src/api.js', () => ({ callApi: (...args) => mockCallApi(...args) }));
|
|
20
|
-
|
|
21
|
-
const { callWithFallback } = await import('../src/fallback.js');
|
|
22
|
-
|
|
23
|
-
const chalk = { red: s => s, dim: s => s, yellow: s => s, bold: s => s, cyan: s => s };
|
|
24
|
-
|
|
25
|
-
describe('callWithFallback /run timeout', () => {
|
|
26
|
-
it('uses a timeout comfortably above the backend\'s 200s provision deadline', async () => {
|
|
27
|
-
await callWithFallback(
|
|
28
|
-
'/run',
|
|
29
|
-
{ apiKey: 'sk-test', baseUrl: 'https://api.test/v1' },
|
|
30
|
-
() => ({ image: 'busybox', command: ['echo', 'hi'] }),
|
|
31
|
-
'1',
|
|
32
|
-
chalk,
|
|
33
|
-
{ thing: 'job', cmd: 'badgr run' },
|
|
34
|
-
);
|
|
35
|
-
|
|
36
|
-
expect(mockCallApi).toHaveBeenCalledTimes(1);
|
|
37
|
-
const [, opts] = mockCallApi.mock.calls[0];
|
|
38
|
-
const BACKEND_PROVISION_TIMEOUT_SEC = 200; // backend/run_serve_routes.py's _PROVISION_TIMEOUT_SEC default
|
|
39
|
-
expect(opts.timeoutMs).toBeGreaterThan(BACKEND_PROVISION_TIMEOUT_SEC * 1000);
|
|
40
|
-
});
|
|
41
|
-
});
|