badgr-cli 1.1.1 → 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 +9 -2
- 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/fanout.test.js
DELETED
|
@@ -1,124 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
-
import { runFanOut } from '../src/fanout.js';
|
|
3
|
-
|
|
4
|
-
describe('runFanOut', () => {
|
|
5
|
-
it('submits and monitors every task, preserving input order in results', async () => {
|
|
6
|
-
const submitTask = vi.fn(async (task) => ({ deploymentId: `dep-${task}`, receiptId: `rcpt-${task}`, ratePerHour: 1 }));
|
|
7
|
-
const monitorTask = vi.fn(async ({ deploymentId }) => ({ status: 'succeeded', succeeded: true, runtimeMs: 1000, deploymentId }));
|
|
8
|
-
|
|
9
|
-
const { results, allSucceeded, submittedCount, failedSubmitCount } = await runFanOut({
|
|
10
|
-
tasks: ['a', 'b', 'c'],
|
|
11
|
-
submitTask,
|
|
12
|
-
monitorTask,
|
|
13
|
-
});
|
|
14
|
-
|
|
15
|
-
expect(submitTask).toHaveBeenCalledTimes(3);
|
|
16
|
-
expect(monitorTask).toHaveBeenCalledTimes(3);
|
|
17
|
-
expect(results.map(r => r.task)).toEqual(['a', 'b', 'c']);
|
|
18
|
-
expect(results.every(r => r.succeeded)).toBe(true);
|
|
19
|
-
expect(allSucceeded).toBe(true);
|
|
20
|
-
expect(submittedCount).toBe(3);
|
|
21
|
-
expect(failedSubmitCount).toBe(0);
|
|
22
|
-
});
|
|
23
|
-
|
|
24
|
-
it('keeps a task that fails to submit out of monitoring, marks it as an error, and reports allSucceeded=false', async () => {
|
|
25
|
-
const submitTask = vi.fn(async (task) => {
|
|
26
|
-
if (task === 'bad') throw new Error('capacity exhausted');
|
|
27
|
-
return { deploymentId: `dep-${task}`, receiptId: `rcpt-${task}`, ratePerHour: 1 };
|
|
28
|
-
});
|
|
29
|
-
const monitorTask = vi.fn(async ({ deploymentId }) => ({ status: 'succeeded', succeeded: true, runtimeMs: 500, deploymentId }));
|
|
30
|
-
const onSubmitError = vi.fn();
|
|
31
|
-
|
|
32
|
-
const { results, allSucceeded, submittedCount, failedSubmitCount } = await runFanOut({
|
|
33
|
-
tasks: ['ok1', 'bad', 'ok2'],
|
|
34
|
-
submitTask,
|
|
35
|
-
monitorTask,
|
|
36
|
-
onSubmitError,
|
|
37
|
-
});
|
|
38
|
-
|
|
39
|
-
expect(monitorTask).toHaveBeenCalledTimes(2);
|
|
40
|
-
expect(onSubmitError).toHaveBeenCalledWith('bad', expect.any(Error));
|
|
41
|
-
expect(results[1]).toMatchObject({ task: 'bad', error: expect.any(Error) });
|
|
42
|
-
expect(results[0].succeeded).toBe(true);
|
|
43
|
-
expect(results[2].succeeded).toBe(true);
|
|
44
|
-
expect(allSucceeded).toBe(false);
|
|
45
|
-
expect(submittedCount).toBe(2);
|
|
46
|
-
expect(failedSubmitCount).toBe(1);
|
|
47
|
-
});
|
|
48
|
-
|
|
49
|
-
it('reports allSucceeded=false when a monitored task does not succeed', async () => {
|
|
50
|
-
const submitTask = vi.fn(async (task) => ({ deploymentId: `dep-${task}`, receiptId: `rcpt-${task}`, ratePerHour: 1 }));
|
|
51
|
-
const monitorTask = vi.fn(async ({ deploymentId }) =>
|
|
52
|
-
deploymentId === 'dep-b'
|
|
53
|
-
? { status: 'failed', succeeded: false, runtimeMs: 200 }
|
|
54
|
-
: { status: 'succeeded', succeeded: true, runtimeMs: 200 });
|
|
55
|
-
|
|
56
|
-
const { allSucceeded, results } = await runFanOut({
|
|
57
|
-
tasks: ['a', 'b'],
|
|
58
|
-
submitTask,
|
|
59
|
-
monitorTask,
|
|
60
|
-
});
|
|
61
|
-
|
|
62
|
-
expect(allSucceeded).toBe(false);
|
|
63
|
-
expect(results.find(r => r.task === 'b').succeeded).toBe(false);
|
|
64
|
-
});
|
|
65
|
-
|
|
66
|
-
it('never exceeds the configured concurrency — no more than N tasks in flight at once', async () => {
|
|
67
|
-
let inFlight = 0;
|
|
68
|
-
let maxInFlight = 0;
|
|
69
|
-
const submitTask = vi.fn(async (task) => ({ deploymentId: `dep-${task}`, receiptId: `rcpt-${task}`, ratePerHour: 1 }));
|
|
70
|
-
const monitorTask = vi.fn(async ({ deploymentId }) => {
|
|
71
|
-
inFlight++;
|
|
72
|
-
maxInFlight = Math.max(maxInFlight, inFlight);
|
|
73
|
-
await new Promise(r => setTimeout(r, 5));
|
|
74
|
-
inFlight--;
|
|
75
|
-
return { status: 'succeeded', succeeded: true, runtimeMs: 5, deploymentId };
|
|
76
|
-
});
|
|
77
|
-
|
|
78
|
-
await runFanOut({ tasks: ['a', 'b', 'c', 'd', 'e', 'f'], submitTask, monitorTask, concurrency: 2 });
|
|
79
|
-
|
|
80
|
-
expect(maxInFlight).toBeLessThanOrEqual(2);
|
|
81
|
-
});
|
|
82
|
-
|
|
83
|
-
it('defaults to a bounded concurrency (5) when none is given — never unbounded', async () => {
|
|
84
|
-
let inFlight = 0;
|
|
85
|
-
let maxInFlight = 0;
|
|
86
|
-
const submitTask = vi.fn(async (task) => ({ deploymentId: `dep-${task}`, receiptId: `rcpt-${task}`, ratePerHour: 1 }));
|
|
87
|
-
const monitorTask = vi.fn(async ({ deploymentId }) => {
|
|
88
|
-
inFlight++;
|
|
89
|
-
maxInFlight = Math.max(maxInFlight, inFlight);
|
|
90
|
-
await new Promise(r => setTimeout(r, 5));
|
|
91
|
-
inFlight--;
|
|
92
|
-
return { status: 'succeeded', succeeded: true, runtimeMs: 5, deploymentId };
|
|
93
|
-
});
|
|
94
|
-
|
|
95
|
-
const tasks = Array.from({ length: 12 }, (_, i) => `t${i}`);
|
|
96
|
-
await runFanOut({ tasks, submitTask, monitorTask });
|
|
97
|
-
|
|
98
|
-
expect(maxInFlight).toBeLessThanOrEqual(5);
|
|
99
|
-
});
|
|
100
|
-
|
|
101
|
-
it('preserves original task order in results even with concurrency > 1', async () => {
|
|
102
|
-
const submitTask = vi.fn(async (task) => ({ deploymentId: `dep-${task}`, receiptId: `rcpt-${task}`, ratePerHour: 1 }));
|
|
103
|
-
const monitorTask = vi.fn(async ({ task, deploymentId }) => {
|
|
104
|
-
// Later tasks resolve faster, to prove ordering isn't just resolution order.
|
|
105
|
-
await new Promise(r => setTimeout(r, task === 'a' ? 20 : 1));
|
|
106
|
-
return { status: 'succeeded', succeeded: true, runtimeMs: 1, deploymentId };
|
|
107
|
-
});
|
|
108
|
-
|
|
109
|
-
const { results } = await runFanOut({ tasks: ['a', 'b', 'c'], submitTask, monitorTask, concurrency: 3 });
|
|
110
|
-
|
|
111
|
-
expect(results.map(r => r.task)).toEqual(['a', 'b', 'c']);
|
|
112
|
-
});
|
|
113
|
-
|
|
114
|
-
it('runs with an empty task list without submitting or monitoring anything', async () => {
|
|
115
|
-
const submitTask = vi.fn();
|
|
116
|
-
const monitorTask = vi.fn();
|
|
117
|
-
const { results, allSucceeded, submittedCount } = await runFanOut({ tasks: [], submitTask, monitorTask });
|
|
118
|
-
expect(submitTask).not.toHaveBeenCalled();
|
|
119
|
-
expect(monitorTask).not.toHaveBeenCalled();
|
|
120
|
-
expect(results).toEqual([]);
|
|
121
|
-
expect(submittedCount).toBe(0);
|
|
122
|
-
expect(allSucceeded).toBe(true); // vacuously true — no tasks, nothing failed
|
|
123
|
-
});
|
|
124
|
-
});
|
|
@@ -1,402 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
-
import { classifyLog } from '../src/gpuDoctor/logClassifier.js';
|
|
3
|
-
import { estimateModelParamsB, detectQuantization, estimateModelFit } from '../src/gpuDoctor/modelFit.js';
|
|
4
|
-
import { diagnoseWorkflow, parseWorkflowFile } from '../src/gpuDoctor/workflowDoctor.js';
|
|
5
|
-
import { checkHealth } from '../src/gpuDoctor/healthCheck.js';
|
|
6
|
-
import { redactLine } from '../src/gpuDoctor/redact.js';
|
|
7
|
-
import { parseDoctorArgs } from '../src/commands/doctor.js';
|
|
8
|
-
|
|
9
|
-
describe('classifyLog — vram_oom', () => {
|
|
10
|
-
it('classifies CUDA OOM + EngineDeadError + worker died as vram_oom', () => {
|
|
11
|
-
const log = [
|
|
12
|
-
'INFO engine starting',
|
|
13
|
-
'torch.cuda.OutOfMemoryError: CUDA out of memory. Tried to allocate 2.00 GiB',
|
|
14
|
-
'ERROR: EngineDeadError: engine core process died',
|
|
15
|
-
'ERROR: worker died',
|
|
16
|
-
].join('\n');
|
|
17
|
-
const result = classifyLog(log);
|
|
18
|
-
expect(result.matched).toBe(true);
|
|
19
|
-
expect(result.category).toBe('vram_oom');
|
|
20
|
-
expect(result.fixes.some((f) => /quantiz/i.test(f))).toBe(true);
|
|
21
|
-
});
|
|
22
|
-
});
|
|
23
|
-
|
|
24
|
-
describe('classifyLog — cuda_pytorch_mismatch', () => {
|
|
25
|
-
it('classifies illegal memory access as cuda mismatch', () => {
|
|
26
|
-
const result = classifyLog('RuntimeError: CUDA error: an illegal memory access was encountered');
|
|
27
|
-
expect(result.category).toBe('cuda_pytorch_mismatch');
|
|
28
|
-
});
|
|
29
|
-
|
|
30
|
-
it('classifies CPU-only torch build message', () => {
|
|
31
|
-
const result = classifyLog('AssertionError: Torch not compiled with CUDA enabled');
|
|
32
|
-
expect(result.category).toBe('cuda_pytorch_mismatch');
|
|
33
|
-
});
|
|
34
|
-
});
|
|
35
|
-
|
|
36
|
-
describe('classifyLog — nccl_distributed', () => {
|
|
37
|
-
it('classifies NCCL timeout', () => {
|
|
38
|
-
const result = classifyLog('[E ProcessGroupNCCL.cpp:475] Watchdog caught collective operation timeout: NCCL timeout');
|
|
39
|
-
expect(result.category).toBe('nccl_distributed');
|
|
40
|
-
});
|
|
41
|
-
});
|
|
42
|
-
|
|
43
|
-
describe('classifyLog — missing_model', () => {
|
|
44
|
-
it('classifies a 404 from the hub as missing model', () => {
|
|
45
|
-
const result = classifyLog('huggingface_hub.utils._errors.RepositoryNotFoundError: 404 Client Error. Repository Not Found for url');
|
|
46
|
-
expect(result.category).toBe('missing_model');
|
|
47
|
-
});
|
|
48
|
-
});
|
|
49
|
-
|
|
50
|
-
describe('classifyLog — missing_dependency', () => {
|
|
51
|
-
it('classifies ModuleNotFoundError', () => {
|
|
52
|
-
const result = classifyLog('ModuleNotFoundError: No module named \'vllm\'');
|
|
53
|
-
expect(result.category).toBe('missing_dependency');
|
|
54
|
-
});
|
|
55
|
-
});
|
|
56
|
-
|
|
57
|
-
describe('classifyLog — health_check', () => {
|
|
58
|
-
it('classifies connection refused as health_check', () => {
|
|
59
|
-
const result = classifyLog('curl: (7) Failed to connect to localhost port 8000: Connection refused');
|
|
60
|
-
expect(result.category).toBe('health_check');
|
|
61
|
-
});
|
|
62
|
-
});
|
|
63
|
-
|
|
64
|
-
describe('classifyLog — provider_pod_issue', () => {
|
|
65
|
-
it('classifies pod preemption', () => {
|
|
66
|
-
const result = classifyLog('WARNING: pod preempted by scheduler, instance reclaimed');
|
|
67
|
-
expect(result.category).toBe('provider_pod_issue');
|
|
68
|
-
});
|
|
69
|
-
});
|
|
70
|
-
|
|
71
|
-
describe('classifyLog — disk_cache', () => {
|
|
72
|
-
it('classifies out-of-disk errors', () => {
|
|
73
|
-
const result = classifyLog('OSError: [Errno 28] No space left on device');
|
|
74
|
-
expect(result.category).toBe('disk_cache');
|
|
75
|
-
});
|
|
76
|
-
});
|
|
77
|
-
|
|
78
|
-
describe('classifyLog — unknown', () => {
|
|
79
|
-
it('falls back to unknown for unrecognized text', () => {
|
|
80
|
-
const result = classifyLog('this is a totally normal log line with no failure signature');
|
|
81
|
-
expect(result.matched).toBe(false);
|
|
82
|
-
expect(result.category).toBe('unknown');
|
|
83
|
-
});
|
|
84
|
-
});
|
|
85
|
-
|
|
86
|
-
describe('modelFit — estimateModelParamsB / detectQuantization', () => {
|
|
87
|
-
it('parses common size labels', () => {
|
|
88
|
-
expect(estimateModelParamsB('Qwen/Qwen2.5-7B-Instruct')).toBe(7);
|
|
89
|
-
expect(estimateModelParamsB('meta-llama/Llama-3.1-8B-Instruct')).toBe(8);
|
|
90
|
-
expect(estimateModelParamsB('Qwen/Qwen2.5-14B-Instruct')).toBe(14);
|
|
91
|
-
expect(estimateModelParamsB('Qwen/Qwen2.5-32B-Instruct')).toBe(32);
|
|
92
|
-
expect(estimateModelParamsB('meta-llama/Llama-3.1-70B-Instruct')).toBe(70);
|
|
93
|
-
});
|
|
94
|
-
|
|
95
|
-
it('returns null when no size label is present', () => {
|
|
96
|
-
expect(estimateModelParamsB('my-custom-model')).toBeNull();
|
|
97
|
-
});
|
|
98
|
-
|
|
99
|
-
it('detects quantization tags', () => {
|
|
100
|
-
expect(detectQuantization('TheBloke/Llama-2-7B-AWQ')).toBe('awq');
|
|
101
|
-
expect(detectQuantization('TheBloke/Llama-2-7B-GPTQ')).toBe('gptq');
|
|
102
|
-
expect(detectQuantization('model-fp8')).toBe('fp8');
|
|
103
|
-
expect(detectQuantization('model-4bit')).toBe('4bit');
|
|
104
|
-
expect(detectQuantization('Qwen/Qwen2.5-7B-Instruct')).toBeNull();
|
|
105
|
-
});
|
|
106
|
-
});
|
|
107
|
-
|
|
108
|
-
describe('estimateModelFit', () => {
|
|
109
|
-
it('estimates a small model comfortably under 24GB', () => {
|
|
110
|
-
const est = estimateModelFit('Qwen/Qwen2.5-7B-Instruct');
|
|
111
|
-
expect(est.paramsB).toBe(7);
|
|
112
|
-
expect(est.vramMaxGb).toBeLessThanOrEqual(24);
|
|
113
|
-
});
|
|
114
|
-
|
|
115
|
-
it('estimates a 70B model as needing 80GB+ class', () => {
|
|
116
|
-
const est = estimateModelFit('meta-llama/Llama-3.1-70B-Instruct');
|
|
117
|
-
expect(est.paramsB).toBe(70);
|
|
118
|
-
expect(est.vramMinGb).toBeGreaterThan(24);
|
|
119
|
-
expect(est.recommendedGpuClass).toMatch(/80GB\+|multi-GPU/);
|
|
120
|
-
});
|
|
121
|
-
|
|
122
|
-
it('returns null estimate for an unrecognized size label', () => {
|
|
123
|
-
const est = estimateModelFit('my-custom-model');
|
|
124
|
-
expect(est.paramsB).toBeNull();
|
|
125
|
-
expect(est.vramMaxGb).toBeNull();
|
|
126
|
-
expect(est.perGpuVramMaxGb).toBeNull();
|
|
127
|
-
});
|
|
128
|
-
|
|
129
|
-
it('defaults to gpuCount 1, where per-GPU figures equal the totals', () => {
|
|
130
|
-
const est = estimateModelFit('meta-llama/Llama-3.1-70B-Instruct');
|
|
131
|
-
expect(est.gpuCount).toBe(1);
|
|
132
|
-
expect(est.perGpuVramMinGb).toBe(est.vramMinGb);
|
|
133
|
-
expect(est.perGpuVramMaxGb).toBe(est.vramMaxGb);
|
|
134
|
-
});
|
|
135
|
-
|
|
136
|
-
it('shards weights across gpuCount, dividing per-GPU VRAM while the total stays the same', () => {
|
|
137
|
-
const single = estimateModelFit('meta-llama/Llama-3.1-70B-Instruct-AWQ', { gpuCount: 1 });
|
|
138
|
-
const quad = estimateModelFit('meta-llama/Llama-3.1-70B-Instruct-AWQ', { gpuCount: 4 });
|
|
139
|
-
expect(quad.vramMaxGb).toBe(single.vramMaxGb);
|
|
140
|
-
expect(quad.perGpuVramMaxGb).toBeLessThan(single.perGpuVramMaxGb);
|
|
141
|
-
expect(quad.recommendedGpuClass).not.toBe(single.recommendedGpuClass);
|
|
142
|
-
});
|
|
143
|
-
|
|
144
|
-
it('treats a fractional or zero gpuCount as 1 rather than dividing by <1 or 0', () => {
|
|
145
|
-
const est = estimateModelFit('Qwen/Qwen2.5-7B-Instruct', { gpuCount: 0 });
|
|
146
|
-
expect(est.gpuCount).toBe(1);
|
|
147
|
-
expect(est.perGpuVramMaxGb).toBe(est.vramMaxGb);
|
|
148
|
-
});
|
|
149
|
-
});
|
|
150
|
-
|
|
151
|
-
describe('diagnoseWorkflow — SDXL', () => {
|
|
152
|
-
it('detects SDXL checkpoint, models, and a custom node', () => {
|
|
153
|
-
const workflow = {
|
|
154
|
-
1: { class_type: 'CheckpointLoaderSimple', inputs: { ckpt_name: 'sd_xl_base_1.0.safetensors' } },
|
|
155
|
-
2: { class_type: 'KSampler', inputs: {} },
|
|
156
|
-
3: { class_type: 'ImpactWildcardEncode', inputs: {} },
|
|
157
|
-
};
|
|
158
|
-
const result = diagnoseWorkflow(workflow);
|
|
159
|
-
expect(result.likelyClass).toBe('SDXL');
|
|
160
|
-
expect(result.models).toContain('sd_xl_base_1.0.safetensors');
|
|
161
|
-
expect(result.customNodes).toContain('ImpactWildcardEncode');
|
|
162
|
-
expect(result.vramBucket).toMatch(/12/);
|
|
163
|
-
});
|
|
164
|
-
});
|
|
165
|
-
|
|
166
|
-
describe('diagnoseWorkflow — Flux', () => {
|
|
167
|
-
it('detects a Flux workflow from checkpoint filename', () => {
|
|
168
|
-
const workflow = {
|
|
169
|
-
1: { class_type: 'UNETLoader', inputs: { unet_name: 'flux1-dev.safetensors' } },
|
|
170
|
-
2: { class_type: 'KSampler', inputs: {} },
|
|
171
|
-
};
|
|
172
|
-
const result = diagnoseWorkflow(workflow);
|
|
173
|
-
expect(result.likelyClass).toBe('Flux');
|
|
174
|
-
});
|
|
175
|
-
});
|
|
176
|
-
|
|
177
|
-
describe('diagnoseWorkflow — parseWorkflowFile', () => {
|
|
178
|
-
it('parses raw JSON text', () => {
|
|
179
|
-
const raw = JSON.stringify({ 1: { class_type: 'CheckpointLoaderSimple', inputs: {} } });
|
|
180
|
-
const workflow = parseWorkflowFile(raw);
|
|
181
|
-
expect(diagnoseWorkflow(workflow).nodeCount).toBe(1);
|
|
182
|
-
});
|
|
183
|
-
|
|
184
|
-
it('throws on invalid JSON', () => {
|
|
185
|
-
expect(() => parseWorkflowFile('not json')).toThrow();
|
|
186
|
-
});
|
|
187
|
-
});
|
|
188
|
-
|
|
189
|
-
describe('checkHealth', () => {
|
|
190
|
-
it('classifies a healthy 200 response as ready', async () => {
|
|
191
|
-
const fetchImpl = async () => new Response(JSON.stringify({ data: [{ id: 'model-a' }] }), { status: 200 });
|
|
192
|
-
const result = await checkHealth('http://localhost:8000/v1/models', { fetchImpl });
|
|
193
|
-
expect(result.reachable).toBe(true);
|
|
194
|
-
expect(result.ready).toBe(true);
|
|
195
|
-
expect(result.modelsCount).toBe(1);
|
|
196
|
-
});
|
|
197
|
-
|
|
198
|
-
it('classifies a non-2xx response as unhealthy', async () => {
|
|
199
|
-
const fetchImpl = async () => new Response('error', { status: 503 });
|
|
200
|
-
const result = await checkHealth('http://localhost:8000/health', { fetchImpl });
|
|
201
|
-
expect(result.reachable).toBe(true);
|
|
202
|
-
expect(result.ready).toBe(false);
|
|
203
|
-
expect(result.classification).toBe('endpoint unhealthy');
|
|
204
|
-
});
|
|
205
|
-
|
|
206
|
-
it('classifies a connection refused error', async () => {
|
|
207
|
-
const err = new Error('fetch failed');
|
|
208
|
-
err.cause = { code: 'ECONNREFUSED' };
|
|
209
|
-
const fetchImpl = async () => { throw err; };
|
|
210
|
-
const result = await checkHealth('http://localhost:1/', { fetchImpl });
|
|
211
|
-
expect(result.reachable).toBe(false);
|
|
212
|
-
expect(result.classification).toBe('connection refused');
|
|
213
|
-
});
|
|
214
|
-
|
|
215
|
-
it('classifies a timeout', async () => {
|
|
216
|
-
const err = new Error('The operation was aborted');
|
|
217
|
-
err.name = 'TimeoutError';
|
|
218
|
-
const fetchImpl = async () => { throw err; };
|
|
219
|
-
const result = await checkHealth('http://localhost:1/', { fetchImpl });
|
|
220
|
-
expect(result.reachable).toBe(false);
|
|
221
|
-
expect(result.classification).toBe('timeout');
|
|
222
|
-
});
|
|
223
|
-
|
|
224
|
-
it('does not crash on a malformed URL string', async () => {
|
|
225
|
-
const result = await checkHealth('not-a-valid-url');
|
|
226
|
-
expect(result.reachable).toBe(false);
|
|
227
|
-
expect(result.status).toBeNull();
|
|
228
|
-
});
|
|
229
|
-
|
|
230
|
-
it('classifies a ComfyUI /system_stats response by its devices array', async () => {
|
|
231
|
-
const fetchImpl = async () => new Response(JSON.stringify({ system: { os: 'posix' }, devices: [{ name: 'cuda:0' }] }), { status: 200 });
|
|
232
|
-
const result = await checkHealth('http://localhost:8188/system_stats', { fetchImpl });
|
|
233
|
-
expect(result.serviceKind).toBe('comfyui');
|
|
234
|
-
expect(result.detail).toMatch(/1 device\(s\) reported/);
|
|
235
|
-
});
|
|
236
|
-
|
|
237
|
-
it('classifies a llama.cpp-style {status} response', async () => {
|
|
238
|
-
const fetchImpl = async () => new Response(JSON.stringify({ status: 'loading model' }), { status: 200 });
|
|
239
|
-
const result = await checkHealth('http://localhost:8080/health', { fetchImpl });
|
|
240
|
-
expect(result.serviceKind).toBe('llamacpp');
|
|
241
|
-
expect(result.detail).toBe('status: loading model');
|
|
242
|
-
});
|
|
243
|
-
|
|
244
|
-
it('classifies a generic {healthy: true/false} response', async () => {
|
|
245
|
-
const fetchImpl = async () => new Response(JSON.stringify({ healthy: false }), { status: 200 });
|
|
246
|
-
const result = await checkHealth('http://localhost:9000/health', { fetchImpl });
|
|
247
|
-
expect(result.serviceKind).toBe('generic');
|
|
248
|
-
expect(result.detail).toBe('healthy: false');
|
|
249
|
-
});
|
|
250
|
-
|
|
251
|
-
it('classifies a bare-text "ready" response', async () => {
|
|
252
|
-
const fetchImpl = async () => new Response('ready', { status: 200 });
|
|
253
|
-
const result = await checkHealth('http://localhost:9001/health', { fetchImpl });
|
|
254
|
-
expect(result.serviceKind).toBe('generic');
|
|
255
|
-
expect(result.detail).toBe('response: ready');
|
|
256
|
-
});
|
|
257
|
-
|
|
258
|
-
it('leaves serviceKind/detail null for an unrecognized JSON body', async () => {
|
|
259
|
-
const fetchImpl = async () => new Response(JSON.stringify({ some: 'thing' }), { status: 200 });
|
|
260
|
-
const result = await checkHealth('http://localhost:9002/health', { fetchImpl });
|
|
261
|
-
expect(result.serviceKind).toBeNull();
|
|
262
|
-
expect(result.detail).toBeNull();
|
|
263
|
-
expect(result.ready).toBe(true);
|
|
264
|
-
});
|
|
265
|
-
});
|
|
266
|
-
|
|
267
|
-
describe('classifyLog — malformed / empty input', () => {
|
|
268
|
-
it('does not crash on an empty log', () => {
|
|
269
|
-
const result = classifyLog('');
|
|
270
|
-
expect(result.matched).toBe(false);
|
|
271
|
-
expect(result.category).toBe('unknown');
|
|
272
|
-
});
|
|
273
|
-
|
|
274
|
-
it('does not crash on binary-ish garbage input', () => {
|
|
275
|
-
const result = classifyLog('\x00\x01\x02\xFF garbage �� not a real log');
|
|
276
|
-
expect(result.category).toBe('unknown');
|
|
277
|
-
});
|
|
278
|
-
});
|
|
279
|
-
|
|
280
|
-
describe('redactLine', () => {
|
|
281
|
-
it('redacts a Hugging Face token', () => {
|
|
282
|
-
const line = 'Authenticating with token hf_abcdefghijklmnopqrstuvwx1234';
|
|
283
|
-
expect(redactLine(line)).not.toMatch(/hf_abcdefghijklmnopqrstuvwx1234/);
|
|
284
|
-
expect(redactLine(line)).toMatch(/hf_\[REDACTED\]/);
|
|
285
|
-
});
|
|
286
|
-
|
|
287
|
-
it('redacts an Authorization header', () => {
|
|
288
|
-
const line = 'Authorization: Bearer sk-proj-abcdefghijklmnop1234567890';
|
|
289
|
-
expect(redactLine(line)).not.toMatch(/abcdefghijklmnop1234567890/);
|
|
290
|
-
});
|
|
291
|
-
|
|
292
|
-
it('redacts credentials embedded in a URL', () => {
|
|
293
|
-
const line = 'Fetching https://user:s3cr3t@example.com/model.bin';
|
|
294
|
-
const out = redactLine(line);
|
|
295
|
-
expect(out).not.toMatch(/s3cr3t/);
|
|
296
|
-
expect(out).toContain('[REDACTED]:[REDACTED]@');
|
|
297
|
-
});
|
|
298
|
-
|
|
299
|
-
it('redacts a home directory username', () => {
|
|
300
|
-
const line = 'Loading model from /home/alice/.cache/huggingface/model.bin';
|
|
301
|
-
expect(redactLine(line)).not.toMatch(/alice/);
|
|
302
|
-
});
|
|
303
|
-
|
|
304
|
-
it('redacts an email address', () => {
|
|
305
|
-
expect(redactLine('Contact: bob@example.com for access')).toContain('[email]');
|
|
306
|
-
});
|
|
307
|
-
|
|
308
|
-
it('leaves ordinary log lines unchanged', () => {
|
|
309
|
-
const line = 'CUDA out of memory. Tried to allocate 2.00 GiB';
|
|
310
|
-
expect(redactLine(line)).toBe(line);
|
|
311
|
-
});
|
|
312
|
-
|
|
313
|
-
it('is applied to evidence lines extracted from a log', () => {
|
|
314
|
-
const log = 'CUDA out of memory while loading with Authorization: Bearer hf_supersecrettoken1234567890';
|
|
315
|
-
const result = classifyLog(log);
|
|
316
|
-
const combined = result.evidenceLines.join(' ');
|
|
317
|
-
expect(combined).not.toMatch(/supersecrettoken1234567890/);
|
|
318
|
-
});
|
|
319
|
-
});
|
|
320
|
-
|
|
321
|
-
describe('diagnoseWorkflow — UI-export format', () => {
|
|
322
|
-
it('parses a UI-export workflow (nodes array with widgets_values) without crashing', () => {
|
|
323
|
-
const workflow = {
|
|
324
|
-
nodes: [
|
|
325
|
-
{ type: 'CheckpointLoaderSimple', widgets_values: ['sd_xl_base_1.0.safetensors'] },
|
|
326
|
-
{ type: 'KSampler', widgets_values: [20, 'euler'] },
|
|
327
|
-
{ type: 'SomeCustomExtensionNode', widgets_values: [] },
|
|
328
|
-
],
|
|
329
|
-
};
|
|
330
|
-
const result = diagnoseWorkflow(workflow);
|
|
331
|
-
expect(result.nodeCount).toBe(3);
|
|
332
|
-
expect(result.likelyClass).toBe('SDXL');
|
|
333
|
-
expect(result.models).toContain('sd_xl_base_1.0.safetensors');
|
|
334
|
-
expect(result.customNodes).toContain('SomeCustomExtensionNode');
|
|
335
|
-
});
|
|
336
|
-
|
|
337
|
-
it('handles an empty or unrecognized workflow shape without throwing', () => {
|
|
338
|
-
expect(() => diagnoseWorkflow({})).not.toThrow();
|
|
339
|
-
expect(() => diagnoseWorkflow(null)).not.toThrow();
|
|
340
|
-
expect(() => diagnoseWorkflow({ nodes: [] })).not.toThrow();
|
|
341
|
-
expect(diagnoseWorkflow(null).nodeCount).toBe(0);
|
|
342
|
-
});
|
|
343
|
-
});
|
|
344
|
-
|
|
345
|
-
describe('parseDoctorArgs', () => {
|
|
346
|
-
it('parses --model and --serve together', () => {
|
|
347
|
-
const flags = parseDoctorArgs(['--model', 'Qwen/Qwen2.5-7B-Instruct', '--serve']);
|
|
348
|
-
expect(flags.model).toBe('Qwen/Qwen2.5-7B-Instruct');
|
|
349
|
-
expect(flags.serve).toBe(true);
|
|
350
|
-
});
|
|
351
|
-
|
|
352
|
-
it('parses --gpu, --vram-gb, and --json', () => {
|
|
353
|
-
const flags = parseDoctorArgs(['--gpu', 'A100', '--vram-gb', '40', '--json']);
|
|
354
|
-
expect(flags.gpu).toBe('A100');
|
|
355
|
-
expect(flags.vramGb).toBe(40);
|
|
356
|
-
expect(flags.json).toBe(true);
|
|
357
|
-
});
|
|
358
|
-
|
|
359
|
-
it('serve defaults to falsy when not passed', () => {
|
|
360
|
-
const flags = parseDoctorArgs(['--model', 'Qwen/Qwen2.5-7B-Instruct']);
|
|
361
|
-
expect(flags.serve).toBeFalsy();
|
|
362
|
-
});
|
|
363
|
-
});
|
|
364
|
-
|
|
365
|
-
describe('badgr doctor has no badgr gpu doctor alias', () => {
|
|
366
|
-
it('commands/doctor.js exports only doctorCommand — no gpuCommand alias', async () => {
|
|
367
|
-
const mod = await import('../src/commands/doctor.js');
|
|
368
|
-
expect(typeof mod.doctorCommand).toBe('function');
|
|
369
|
-
expect(mod.gpuCommand).toBeUndefined();
|
|
370
|
-
});
|
|
371
|
-
});
|
|
372
|
-
|
|
373
|
-
describe('badgr doctor --help', () => {
|
|
374
|
-
const chalkStub = new Proxy(() => {}, { get: () => (s) => s, apply: (_t, _a, [s]) => s });
|
|
375
|
-
|
|
376
|
-
async function captureLogs(args) {
|
|
377
|
-
const { doctorCommand } = await import('../src/commands/doctor.js');
|
|
378
|
-
const logs = [];
|
|
379
|
-
const spy = vi.spyOn(console, 'log').mockImplementation((...a) => logs.push(a.join(' ')));
|
|
380
|
-
await doctorCommand({}, args, chalkStub);
|
|
381
|
-
spy.mockRestore();
|
|
382
|
-
return logs.join('\n');
|
|
383
|
-
}
|
|
384
|
-
|
|
385
|
-
it('--help prints usage without running any probes', async () => {
|
|
386
|
-
const output = await captureLogs(['--help']);
|
|
387
|
-
expect(output).toMatch(/Usage:/);
|
|
388
|
-
expect(output).toMatch(/--logs <path>/);
|
|
389
|
-
expect(output).not.toMatch(/Verdict:/);
|
|
390
|
-
});
|
|
391
|
-
|
|
392
|
-
it('-h is also accepted', async () => {
|
|
393
|
-
const output = await captureLogs(['-h']);
|
|
394
|
-
expect(output).toMatch(/Usage:/);
|
|
395
|
-
});
|
|
396
|
-
|
|
397
|
-
it('--help takes priority even when other flags are also passed', async () => {
|
|
398
|
-
const output = await captureLogs(['--model', 'Qwen/Qwen2.5-7B-Instruct', '--help']);
|
|
399
|
-
expect(output).toMatch(/Usage:/);
|
|
400
|
-
expect(output).not.toMatch(/Verdict:/);
|
|
401
|
-
});
|
|
402
|
-
});
|