badgr-cli 1.0.47 → 1.1.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.
Files changed (68) hide show
  1. package/README.md +38 -0
  2. package/package.json +1 -1
  3. package/src/api.js +16 -2
  4. package/src/artifactDownload.js +55 -0
  5. package/src/badgr.js +104 -0
  6. package/src/batch.js +22 -4
  7. package/src/browser.js +23 -0
  8. package/src/commands/artifacts.js +75 -0
  9. package/src/commands/batch.js +221 -20
  10. package/src/commands/billing.js +1 -12
  11. package/src/commands/capacity.js +9 -4
  12. package/src/commands/comfyui.js +3 -3
  13. package/src/commands/connect.js +83 -0
  14. package/src/commands/doctor.js +127 -0
  15. package/src/commands/down.js +29 -6
  16. package/src/commands/launch.js +431 -0
  17. package/src/commands/pull.js +137 -0
  18. package/src/commands/run.js +253 -37
  19. package/src/commands/sbatch.js +232 -0
  20. package/src/commands/serve.js +3 -3
  21. package/src/commands/status.js +12 -4
  22. package/src/commands/task.js +25 -0
  23. package/src/commands/test-run.js +4 -2
  24. package/src/credentials.js +65 -0
  25. package/src/fallback.js +7 -2
  26. package/src/fanout.js +70 -0
  27. package/src/gpuDoctor/diskInfo.js +42 -0
  28. package/src/gpuDoctor/doctor.js +451 -0
  29. package/src/gpuDoctor/gpuInfo.js +70 -0
  30. package/src/gpuDoctor/healthCheck.js +63 -0
  31. package/src/gpuDoctor/logClassifier.js +138 -0
  32. package/src/gpuDoctor/modelFit.js +107 -0
  33. package/src/gpuDoctor/probeCache.js +38 -0
  34. package/src/gpuDoctor/redact.js +29 -0
  35. package/src/gpuDoctor/torchInfo.js +61 -0
  36. package/src/gpuDoctor/workflowDoctor.js +96 -0
  37. package/src/onboarding.js +124 -0
  38. package/src/slurm.js +193 -0
  39. package/src/spec.js +59 -2
  40. package/src/store.js +16 -0
  41. package/tests/agent-images.test.js +17 -0
  42. package/tests/artifactDownload.test.js +113 -0
  43. package/tests/artifacts.test.js +168 -0
  44. package/tests/batch.test.js +312 -0
  45. package/tests/browser.test.js +51 -0
  46. package/tests/capacity.test.js +68 -0
  47. package/tests/commands.test.js +44 -0
  48. package/tests/connect.test.js +83 -0
  49. package/tests/down.test.js +23 -1
  50. package/tests/fallback-timeout.test.js +41 -0
  51. package/tests/fanout.test.js +124 -0
  52. package/tests/gpu-doctor-classifiers.test.js +402 -0
  53. package/tests/gpu-doctor-doctor.test.js +304 -0
  54. package/tests/gpu-doctor-probe-cache.test.js +110 -0
  55. package/tests/gpu-doctor-probes.test.js +257 -0
  56. package/tests/launch-command-argv.test.js +93 -0
  57. package/tests/launch-readiness.test.js +1 -0
  58. package/tests/launch.test.js +440 -0
  59. package/tests/onboarding.test.js +134 -0
  60. package/tests/pull.test.js +266 -0
  61. package/tests/run-lifecycle.test.js +405 -6
  62. package/tests/sbatch.test.js +190 -0
  63. package/tests/secrets.test.js +16 -0
  64. package/tests/slurm.test.js +77 -0
  65. package/tests/spec.test.js +59 -1
  66. package/tests/status.test.js +73 -0
  67. package/tests/task.test.js +109 -0
  68. package/tests/template.test.js +7 -0
package/src/slurm.js ADDED
@@ -0,0 +1,193 @@
1
+ /**
2
+ * Slurm batch-script translation — `badgr sbatch job.slurm`.
3
+ *
4
+ * Not full Slurm compatibility. Parses the common 20% of #SBATCH directives
5
+ * (per the workload-primitives spec: CPU, memory, GPU count, time limit,
6
+ * job arrays, environment variables, the command line, output logs) and
7
+ * translates them into the same normalized job shape `badgr run`/`badgr
8
+ * batch` already submit to /run — no new execution backend, just another
9
+ * entry point into it.
10
+ *
11
+ * Unrecognized #SBATCH directives are collected and surfaced as warnings
12
+ * rather than silently dropped or hard failures — obscure scheduler
13
+ * features are out of scope until users demand them, but users should know
14
+ * what got ignored.
15
+ */
16
+ import { readFileSync } from 'fs';
17
+ import { basename } from 'path';
18
+ import { parseGbSize } from './spec.js';
19
+
20
+ export class SlurmParseError extends Error {}
21
+
22
+ // Directives we understand and translate. Everything else in an #SBATCH
23
+ // line is reported as an "ignored directive" warning, not an error — Slurm
24
+ // scripts commonly carry cluster-specific flags (--partition, --qos,
25
+ // --account, --mail-user, ...) that have no meaning for a single-provider
26
+ // GPU marketplace and are safe to no-op.
27
+ const KNOWN_DIRECTIVE_RE = new RegExp(
28
+ '^(job-name|cpus-per-task|mem|mem-per-cpu|gres|gpus|gpus-per-task|array|time|output|error|export)$',
29
+ );
30
+
31
+ // Slurm's own convention: a bare --mem value with no suffix means MB.
32
+ function parseMemToGb(raw) {
33
+ return parseGbSize(raw, { bareUnit: 'M' });
34
+ }
35
+
36
+ function parseTimeToMinutes(raw) {
37
+ // Slurm --time formats: "minutes", "minutes:seconds", "hours:minutes:seconds",
38
+ // "days-hours", "days-hours:minutes", "days-hours:minutes:seconds"
39
+ const t = raw.trim();
40
+ let days = 0;
41
+ let rest = t;
42
+ if (t.includes('-')) {
43
+ [days, rest] = t.split('-');
44
+ days = parseInt(days, 10);
45
+ }
46
+ const parts = rest.split(':').map(Number);
47
+ let h = 0, m = 0, s = 0;
48
+ if (parts.length === 1) [m] = parts;
49
+ else if (parts.length === 2) [m, s] = parts;
50
+ else if (parts.length === 3) [h, m, s] = parts;
51
+ if (parts.some(Number.isNaN)) return null;
52
+ return days * 24 * 60 + h * 60 + m + Math.ceil(s / 60);
53
+ }
54
+
55
+ function parseArrayRange(raw) {
56
+ // Supports "1-100", "1-100:5" (step), "1,3,5-8" (list + ranges), "1-10%4" (throttle, ignored)
57
+ const throttleIdx = raw.indexOf('%');
58
+ const spec = throttleIdx === -1 ? raw : raw.slice(0, throttleIdx);
59
+ const indices = new Set();
60
+ for (const part of spec.split(',')) {
61
+ const stepIdx = part.indexOf(':');
62
+ const rangePart = stepIdx === -1 ? part : part.slice(0, stepIdx);
63
+ const step = stepIdx === -1 ? 1 : parseInt(part.slice(stepIdx + 1), 10) || 1;
64
+ if (rangePart.includes('-')) {
65
+ const [start, end] = rangePart.split('-').map(Number);
66
+ if (Number.isNaN(start) || Number.isNaN(end)) continue;
67
+ for (let i = start; i <= end; i += step) indices.add(i);
68
+ } else {
69
+ const n = Number(rangePart);
70
+ if (!Number.isNaN(n)) indices.add(n);
71
+ }
72
+ }
73
+ return [...indices].sort((a, b) => a - b);
74
+ }
75
+
76
+ function parseGres(raw) {
77
+ // "gpu:2", "gpu:a100:2", "gpu:1"
78
+ const parts = raw.split(':');
79
+ if (parts[0] !== 'gpu') return { gpuCount: 0, gpuType: null };
80
+ if (parts.length === 2) return { gpuCount: parseInt(parts[1], 10) || 1, gpuType: null };
81
+ if (parts.length >= 3) return { gpuCount: parseInt(parts[2], 10) || 1, gpuType: parts[1] };
82
+ return { gpuCount: 1, gpuType: null };
83
+ }
84
+
85
+ /**
86
+ * Parse a Slurm batch script into a normalized job spec.
87
+ * Throws SlurmParseError if there is no runnable command line.
88
+ */
89
+ export function parseSlurmScript(text, { filename = 'job.slurm' } = {}) {
90
+ const lines = text.split('\n');
91
+ const directives = {};
92
+ const ignoredDirectives = [];
93
+ const exportEnv = {};
94
+ const scriptLines = [];
95
+
96
+ for (const line of lines) {
97
+ const trimmed = line.trim();
98
+ const sbatchMatch = /^#SBATCH\s+(.*)$/.exec(trimmed);
99
+ if (sbatchMatch) {
100
+ const body = sbatchMatch[1].trim();
101
+ const eqMatch = /^--?([\w-]+)(?:[=\s]+(.*))?$/.exec(body);
102
+ if (!eqMatch) continue;
103
+ const key = eqMatch[1];
104
+ const value = (eqMatch[2] ?? '').trim();
105
+ if (!KNOWN_DIRECTIVE_RE.test(key)) {
106
+ ignoredDirectives.push(body);
107
+ continue;
108
+ }
109
+ directives[key] = value;
110
+ continue;
111
+ }
112
+ if (trimmed.startsWith('#!') || trimmed.startsWith('#')) continue; // shebang/comment
113
+ if (trimmed === '') continue;
114
+ // "export KEY=VALUE" lines in the script body, in addition to --export=
115
+ const exportMatch = /^export\s+([\w]+)=(.*)$/.exec(trimmed);
116
+ if (exportMatch) {
117
+ exportEnv[exportMatch[1]] = exportMatch[2].replace(/^["']|["']$/g, '');
118
+ continue;
119
+ }
120
+ scriptLines.push(line);
121
+ }
122
+
123
+ if (scriptLines.length === 0) {
124
+ throw new SlurmParseError(`No command found in ${filename} — a Slurm script needs at least one non-#SBATCH, non-comment line to run.`);
125
+ }
126
+
127
+ if (directives.export) {
128
+ for (const kv of directives.export.split(',')) {
129
+ const idx = kv.indexOf('=');
130
+ if (idx > 0) exportEnv[kv.slice(0, idx).trim()] = kv.slice(idx + 1).trim();
131
+ }
132
+ }
133
+
134
+ const cpus = directives['cpus-per-task'] ? parseInt(directives['cpus-per-task'], 10) : null;
135
+
136
+ let memGb = null;
137
+ if (directives.mem) {
138
+ memGb = parseMemToGb(directives.mem);
139
+ } else if (directives['mem-per-cpu'] && cpus) {
140
+ const perCpu = parseMemToGb(directives['mem-per-cpu']);
141
+ memGb = perCpu != null ? perCpu * cpus : null;
142
+ }
143
+
144
+ let gpuCount = 0;
145
+ let gpuType = null;
146
+ if (directives.gres) {
147
+ ({ gpuCount, gpuType } = parseGres(directives.gres));
148
+ } else if (directives.gpus || directives['gpus-per-task']) {
149
+ const raw = directives.gpus ?? directives['gpus-per-task'];
150
+ gpuCount = parseInt(raw, 10) || (raw.includes(':') ? parseInt(raw.split(':')[1], 10) : 1) || 1;
151
+ }
152
+
153
+ const timeMinutes = directives.time ? parseTimeToMinutes(directives.time) : null;
154
+ const arrayIndices = directives.array ? parseArrayRange(directives.array) : [];
155
+
156
+ return {
157
+ name: directives['job-name'] || basename(filename).replace(/\.slurm$|\.sbatch$/i, ''),
158
+ command: scriptLines.join('\n'),
159
+ cpus,
160
+ memGb,
161
+ gpuCount,
162
+ gpuType,
163
+ timeMinutes,
164
+ arrayIndices, // empty = not a job array
165
+ env: exportEnv,
166
+ outputPattern: directives.output || null,
167
+ errorPattern: directives.error || null,
168
+ ignoredDirectives,
169
+ };
170
+ }
171
+
172
+ export function loadSlurmScript(path) {
173
+ let text;
174
+ try {
175
+ text = readFileSync(path, 'utf8');
176
+ } catch (err) {
177
+ throw new SlurmParseError(`Could not read ${path}: ${err.message}`);
178
+ }
179
+ return parseSlurmScript(text, { filename: basename(path) });
180
+ }
181
+
182
+ /**
183
+ * Substitute Slurm's %a (array task id) / %A (array job id) / %j (job id)
184
+ * placeholders in --output/--error patterns, and expose SLURM_ARRAY_TASK_ID
185
+ * the way a real Slurm runtime would for scripts that read it themselves.
186
+ */
187
+ export function envForArrayTask(baseEnv, taskId, arrayJobId) {
188
+ return {
189
+ ...baseEnv,
190
+ ...(taskId != null ? { SLURM_ARRAY_TASK_ID: String(taskId) } : {}),
191
+ ...(arrayJobId != null ? { SLURM_ARRAY_JOB_ID: String(arrayJobId) } : {}),
192
+ };
193
+ }
package/src/spec.js CHANGED
@@ -4,8 +4,28 @@
4
4
  */
5
5
 
6
6
  export const WORKLOAD_TYPES = ['endpoint', 'job'];
7
+ export const CPU_SIZES = ['small', 'medium', 'large'];
7
8
  export const REGIONS = ['US', 'EU', 'AU'];
8
9
 
10
+ // Phase 1 VM class catalogue for `badgr launch <workload>` — deterministic,
11
+ // not a dynamic autosizing model. vcpu/ramGb are the stable Badgr-facing
12
+ // numbers shown in the pre-launch plan; the exact provider SKU behind each
13
+ // class (BADGR_CPU_FLAVOR_<SIZE>) can change without these changing.
14
+ export const VM_CLASSES = {
15
+ small: { vcpu: 2, ramGb: 4, label: 'general coding jobs' },
16
+ medium: { vcpu: 4, ramGb: 8, label: 'larger repositories and test suites' },
17
+ browser: { vcpu: 4, ramGb: 8, label: 'Chromium and Playwright workloads' },
18
+ };
19
+
20
+ export const LAUNCH_VM_SIZES = Object.keys(VM_CLASSES);
21
+
22
+ // Deterministic workload → VM class table (Phase 1 rule, not autosizing):
23
+ // playwright gets the browser class; every other agent/explicit launch
24
+ // gets small. `--size` always overrides this default.
25
+ export function vmClassForWorkload(workloadName) {
26
+ return workloadName === 'playwright' ? 'browser' : 'small';
27
+ }
28
+
9
29
  // Match canonical GPU IDs used in overflow_providers.py GPU_ALIASES
10
30
  export const GPU_TYPE_MAP = {
11
31
  'rtx-4090': 'RTX_4090', 'rtx4090': 'RTX_4090', '4090': 'RTX_4090',
@@ -18,6 +38,18 @@ export const GPU_TYPE_MAP = {
18
38
  'l40s': 'L40S',
19
39
  };
20
40
 
41
+ export function normalizeCompute(input) {
42
+ const value = String(input || 'gpu').toLowerCase();
43
+ return value === 'cpu' ? 'cpu' : 'gpu';
44
+ }
45
+
46
+ export function selectCpuSize({ agent = null, task = '' } = {}) {
47
+ const text = `${agent || ''} ${task || ''}`.toLowerCase();
48
+ if (/monorepo|large|full repo|e2e|playwright/.test(text)) return 'large';
49
+ if (/test|refactor|build|typecheck/.test(text)) return 'medium';
50
+ return 'small';
51
+ }
52
+
21
53
  export function normalizeGpuType(input) {
22
54
  if (!input) return 'RTX_4090';
23
55
  const lower = input.toLowerCase().replace(/[_\s]/g, '-');
@@ -52,11 +84,15 @@ export function parseSpec(args) {
52
84
  ?? (flags.job ? 'job' : null)
53
85
  ?? (hasImage ? 'job' : 'endpoint');
54
86
 
87
+ const compute = normalizeCompute(flags.compute);
88
+
55
89
  return {
56
90
  type,
91
+ compute,
57
92
  model: flags.model ?? (type === 'endpoint' ? 'meta-llama/Llama-3.1-8B-Instruct' : null),
58
93
  image: flags.image ?? (type === 'job' ? 'vllm/vllm-openai:latest' : null),
59
- gpu: normalizeGpuType(flags.gpu),
94
+ gpu: compute === 'cpu' ? 'CPU' : normalizeGpuType(flags.gpu),
95
+ cpuSize: compute === 'cpu' ? selectCpuSize({ agent: flags.agent, task: flags.task }) : null,
60
96
  count: Math.max(1, parseInt(flags.count ?? '1', 10)),
61
97
  region: (flags.region ?? 'US').toUpperCase(),
62
98
  maxPrice: flags['max-price'] ? parseFloat(flags['max-price']) : null,
@@ -79,12 +115,33 @@ export function validateSpec(spec) {
79
115
  return errors;
80
116
  }
81
117
 
118
+ /**
119
+ * Parse a size like "64GB", "64G", "65536MB", "24576" into a GB float.
120
+ * Shared by `badgr run --memory`/`--gpu-memory` and the Slurm `#SBATCH
121
+ * --mem` translator (src/slurm.js) — one parser, two callers, so "64GB"
122
+ * means the same 64 either way.
123
+ * @param {string|number} raw
124
+ * @param {{ bareUnit?: 'K'|'M'|'G'|'T' }} [opts] - unit assumed when no
125
+ * suffix is given. CLI flags default to GB ("64" -> 64GB); Slurm's own
126
+ * convention for a bare --mem value is MB, so its caller overrides this.
127
+ * @returns {number|null} GB, or null if unparseable.
128
+ */
129
+ export function parseGbSize(raw, { bareUnit = 'G' } = {}) {
130
+ if (raw == null) return null;
131
+ const m = /^(\d+(?:\.\d+)?)\s*([KMGT]?)B?$/i.exec(String(raw).trim());
132
+ if (!m) return null;
133
+ const value = parseFloat(m[1]);
134
+ const unit = (m[2] || bareUnit).toUpperCase();
135
+ const multiplierRelativeToGb = { K: 1 / 1024 / 1024, M: 1 / 1024, G: 1, T: 1024 };
136
+ return value * multiplierRelativeToGb[unit];
137
+ }
138
+
82
139
  export function specLines(spec) {
83
140
  return [
84
141
  `type: ${spec.type}`,
85
142
  spec.model ? `model: ${spec.model}` : null,
86
143
  spec.image ? `image: ${spec.image}` : null,
87
- `gpu: ${spec.gpu} × ${spec.count}`,
144
+ spec.compute === 'cpu' ? `compute: cpu (${spec.cpuSize})` : `gpu: ${spec.gpu} × ${spec.count}`,
88
145
  `region: ${spec.region}`,
89
146
  spec.maxPrice != null ? `max: $${spec.maxPrice.toFixed(2)}/GPU-hr` : null,
90
147
  spec.name ? `name: ${spec.name}` : null,
package/src/store.js CHANGED
@@ -75,6 +75,22 @@ export function listDeployments(storeFile = STORE_FILE) {
75
75
  return loadStore(storeFile).deployments;
76
76
  }
77
77
 
78
+ /**
79
+ * The "what did we actually provision" half of a receipt's compute record —
80
+ * shared by every command that writes a receipt after a /run response
81
+ * (badgr run, badgr sbatch, badgr batch run, badgr batch run --fan-out) so
82
+ * the shape can't drift between them.
83
+ */
84
+ export function selectedComputeFromDeployment(dep) {
85
+ return {
86
+ gpu: dep.gpu_type ?? null,
87
+ gpuCount: dep.gpu_count ?? null,
88
+ vcpus: dep.selected_vcpus ?? null,
89
+ ramGb: dep.selected_ram_gb ?? null,
90
+ vramGb: dep.selected_vram_gb ?? null,
91
+ };
92
+ }
93
+
78
94
  export function addReceipt(receipt, storeFile = STORE_FILE) {
79
95
  const store = loadStore(storeFile);
80
96
  store.receipts.unshift(receipt); // newest first
@@ -0,0 +1,17 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { readFileSync } from 'fs';
3
+ import { resolve } from 'path';
4
+
5
+ const root = resolve(import.meta.dirname, '../../..');
6
+
7
+ describe('Phase 1 agent runtime images', () => {
8
+ it.each([
9
+ ['claude', 'images/badgr-agent-claude/Dockerfile', '@anthropic-ai/claude-code'],
10
+ ['codex', 'images/badgr-agent-codex/Dockerfile', '@openai/codex'],
11
+ ['opencode', 'images/badgr-agent-opencode/Dockerfile', 'opencode-ai'],
12
+ ])('%s image extends the Badgr job runner and installs the CLI', (_name, file, pkg) => {
13
+ const text = readFileSync(resolve(root, file), 'utf8');
14
+ expect(text).toContain('badgr-job-runner');
15
+ expect(text).toContain(pkg);
16
+ });
17
+ });
@@ -0,0 +1,113 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
+ import fs from 'fs';
3
+ import os from 'os';
4
+ import path from 'path';
5
+ import { artifactDownloadUrl } from '../src/artifactDownload.js';
6
+
7
+ describe('artifactDownloadUrl', () => {
8
+ it('appends /v1/deployments/.../artifacts/download when baseUrl already ends in /v1', () => {
9
+ expect(artifactDownloadUrl('https://aibadgr.com/v1', 'dep-abc')).toBe(
10
+ 'https://aibadgr.com/v1/deployments/dep-abc/artifacts/download',
11
+ );
12
+ });
13
+
14
+ it('appends /v1 when baseUrl has no /v1 suffix', () => {
15
+ expect(artifactDownloadUrl('https://aibadgr.com', 'dep-abc')).toBe(
16
+ 'https://aibadgr.com/v1/deployments/dep-abc/artifacts/download',
17
+ );
18
+ });
19
+
20
+ it('normalizes a trailing slash on baseUrl', () => {
21
+ expect(artifactDownloadUrl('https://aibadgr.com/v1/', 'dep-abc')).toBe(
22
+ 'https://aibadgr.com/v1/deployments/dep-abc/artifacts/download',
23
+ );
24
+ });
25
+
26
+ it('treats a missing/empty baseUrl as an empty string prefix', () => {
27
+ expect(artifactDownloadUrl('', 'dep-abc')).toBe('/v1/deployments/dep-abc/artifacts/download');
28
+ expect(artifactDownloadUrl(undefined, 'dep-abc')).toBe('/v1/deployments/dep-abc/artifacts/download');
29
+ });
30
+ });
31
+
32
+ let tarExtractBehavior = () => {};
33
+
34
+ // Matches the real node-tar v7 export shape: named exports only, no
35
+ // default — the mock previously provided both shapes ({ default: { x }, x
36
+ // }), which silently masked a real bug where the source destructured
37
+ // `{ default: tar }` and crashed with "Cannot read properties of undefined
38
+ // (reading 'x')" on every real extraction (found via a live artifact
39
+ // download, never caught by this test).
40
+ vi.mock('tar', () => {
41
+ const x = vi.fn(async opts => tarExtractBehavior(opts));
42
+ return { x };
43
+ });
44
+
45
+ const { downloadAndExtractArtifact } = await import('../src/artifactDownload.js');
46
+
47
+ describe('downloadAndExtractArtifact', () => {
48
+ const config = { apiKey: 'test-key', baseUrl: 'https://example.test/v1' };
49
+ let destDir;
50
+
51
+ beforeEach(() => {
52
+ tarExtractBehavior = ({ cwd }) => {
53
+ fs.writeFileSync(path.join(cwd, 'marker.txt'), 'ok');
54
+ };
55
+ destDir = path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'badgr-artifact-dl-')), 'dest');
56
+ });
57
+
58
+ afterEach(() => {
59
+ vi.restoreAllMocks();
60
+ delete global.fetch;
61
+ fs.rmSync(path.dirname(destDir), { recursive: true, force: true });
62
+ });
63
+
64
+ it('creates destDir, downloads, and extracts', async () => {
65
+ global.fetch = vi.fn().mockResolvedValue({ ok: true, arrayBuffer: async () => new ArrayBuffer(8) });
66
+ await downloadAndExtractArtifact(config, 'dep-abc', destDir);
67
+
68
+ expect(fs.existsSync(destDir)).toBe(true);
69
+ expect(fs.existsSync(path.join(destDir, 'marker.txt'))).toBe(true);
70
+ expect(global.fetch).toHaveBeenCalledWith(
71
+ 'https://example.test/v1/deployments/dep-abc/artifacts/download',
72
+ { headers: { Authorization: 'Bearer test-key' } },
73
+ );
74
+ });
75
+
76
+ it('throws an Error with httpStatus/statusText/bodyText set on a non-OK response', async () => {
77
+ global.fetch = vi.fn().mockResolvedValue({ ok: false, status: 404, statusText: 'Not Found', text: async () => 'nope' });
78
+ await expect(downloadAndExtractArtifact(config, 'dep-missing', destDir)).rejects.toMatchObject({
79
+ httpStatus: 404,
80
+ statusText: 'Not Found',
81
+ bodyText: 'nope',
82
+ });
83
+ });
84
+
85
+ it('includes the response body text in the default error message', async () => {
86
+ global.fetch = vi.fn().mockResolvedValue({ ok: false, status: 500, statusText: 'Internal Server Error', text: async () => 'boom' });
87
+ await expect(downloadAndExtractArtifact(config, 'dep-broken', destDir)).rejects.toThrow(/500 Internal Server Error — boom/);
88
+ });
89
+
90
+ it('propagates a network-level failure with no httpStatus set', async () => {
91
+ global.fetch = vi.fn().mockRejectedValue(new Error('getaddrinfo ENOTFOUND'));
92
+ let caught;
93
+ try {
94
+ await downloadAndExtractArtifact(config, 'dep-offline', destDir);
95
+ } catch (err) {
96
+ caught = err;
97
+ }
98
+ expect(caught).toBeDefined();
99
+ expect(caught.message).toBe('getaddrinfo ENOTFOUND');
100
+ expect(caught.httpStatus).toBeUndefined();
101
+ });
102
+
103
+ it('cleans up the temp tarball file after extraction', async () => {
104
+ let capturedTmpFile;
105
+ tarExtractBehavior = ({ file }) => { capturedTmpFile = file; };
106
+ global.fetch = vi.fn().mockResolvedValue({ ok: true, arrayBuffer: async () => new ArrayBuffer(8) });
107
+
108
+ await downloadAndExtractArtifact(config, 'dep-cleanup', destDir);
109
+
110
+ expect(capturedTmpFile).toBeTruthy();
111
+ expect(fs.existsSync(capturedTmpFile)).toBe(false);
112
+ });
113
+ });
@@ -0,0 +1,168 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach, afterAll } from 'vitest';
2
+ import fs from 'fs';
3
+ import os from 'os';
4
+ import path from 'path';
5
+
6
+ const _fakeConfigDir = fs.mkdtempSync(path.join(os.tmpdir(), 'badgr-artifacts-configdir-'));
7
+
8
+ vi.mock('../src/config.js', async (importOriginal) => {
9
+ const actual = await importOriginal();
10
+ return { ...actual, CONFIG_DIR: _fakeConfigDir };
11
+ });
12
+
13
+ const { parseArtifactsArgs, listFilesRecursive, artifactsCommand } = await import('../src/commands/artifacts.js');
14
+
15
+ afterAll(() => {
16
+ fs.rmSync(_fakeConfigDir, { recursive: true, force: true });
17
+ });
18
+
19
+ describe('parseArtifactsArgs', () => {
20
+ it('parses the deployment id and --output', () => {
21
+ expect(parseArtifactsArgs(['dep-123'])).toEqual({ deploymentId: 'dep-123', flags: {} });
22
+ expect(parseArtifactsArgs(['dep-123', '--output', './out'])).toEqual({
23
+ deploymentId: 'dep-123',
24
+ flags: { output: './out' },
25
+ });
26
+ });
27
+
28
+ it('returns an undefined deploymentId when no positional arg is given', () => {
29
+ expect(parseArtifactsArgs(['--output', './out']).deploymentId).toBeUndefined();
30
+ });
31
+
32
+ it('parses the deployment id without any flags', () => {
33
+ expect(parseArtifactsArgs(['dep-xyz'])).toEqual({ deploymentId: 'dep-xyz', flags: {} });
34
+ });
35
+ });
36
+
37
+ describe('listFilesRecursive', () => {
38
+ let dir;
39
+
40
+ beforeEach(() => {
41
+ dir = fs.mkdtempSync(path.join(os.tmpdir(), 'badgr-artifacts-test-'));
42
+ });
43
+
44
+ afterEach(() => {
45
+ fs.rmSync(dir, { recursive: true, force: true });
46
+ });
47
+
48
+ it('lists nested files with relative paths', () => {
49
+ fs.mkdirSync(path.join(dir, 'playwright-report'), { recursive: true });
50
+ fs.writeFileSync(path.join(dir, 'playwright-report', 'index.html'), '<html></html>');
51
+ fs.writeFileSync(path.join(dir, 'summary.json'), '{}');
52
+
53
+ const files = listFilesRecursive(dir);
54
+ expect(files).toEqual(['playwright-report/index.html', 'summary.json']);
55
+ });
56
+
57
+ it('returns an empty array for an empty directory', () => {
58
+ expect(listFilesRecursive(dir)).toEqual([]);
59
+ });
60
+
61
+ it('lists deeply nested files and sorts the result', () => {
62
+ fs.mkdirSync(path.join(dir, 'a', 'b', 'c'), { recursive: true });
63
+ fs.writeFileSync(path.join(dir, 'a', 'b', 'c', 'deep.txt'), 'x');
64
+ fs.writeFileSync(path.join(dir, 'zzz.txt'), 'x');
65
+ fs.writeFileSync(path.join(dir, 'aaa.txt'), 'x');
66
+
67
+ expect(listFilesRecursive(dir)).toEqual(['a/b/c/deep.txt', 'aaa.txt', 'zzz.txt']);
68
+ });
69
+ });
70
+
71
+ let tarExtractBehavior = () => {};
72
+
73
+ vi.mock('tar', () => {
74
+ const x = vi.fn(async opts => tarExtractBehavior(opts));
75
+ return { default: { x }, x };
76
+ });
77
+
78
+ function extractTestResults({ cwd }) {
79
+ fs.mkdirSync(path.join(cwd, 'test-results'), { recursive: true });
80
+ fs.writeFileSync(path.join(cwd, 'test-results', 'trace.zip'), 'fake-zip');
81
+ }
82
+
83
+ function extractNothing() {
84
+ // empty artifact — no files written
85
+ }
86
+
87
+ describe('artifactsCommand', () => {
88
+ const chalk = { red: s => s, dim: s => s, green: s => s };
89
+ const config = { apiKey: 'test-key', baseUrl: 'https://example.test/v1' };
90
+ let destDir;
91
+
92
+ beforeEach(() => {
93
+ process.exitCode = undefined;
94
+ tarExtractBehavior = extractTestResults;
95
+ vi.spyOn(console, 'log').mockImplementation(() => {});
96
+ vi.spyOn(console, 'error').mockImplementation(() => {});
97
+ destDir = fs.mkdtempSync(path.join(os.tmpdir(), 'badgr-artifacts-dest-'));
98
+ });
99
+
100
+ afterEach(() => {
101
+ vi.restoreAllMocks();
102
+ process.exitCode = undefined;
103
+ delete global.fetch;
104
+ fs.rmSync(destDir, { recursive: true, force: true });
105
+ });
106
+
107
+ it('requires a deployment id', async () => {
108
+ await artifactsCommand(config, [], chalk);
109
+ expect(process.exitCode).toBe(1);
110
+ const logged = console.error.mock.calls.map(c => c.join(' ')).join('\n');
111
+ expect(logged).toContain('Usage');
112
+ });
113
+
114
+ it('reports a clear error on 404 (no artifact uploaded)', async () => {
115
+ global.fetch = vi.fn().mockResolvedValue({ ok: false, status: 404, statusText: 'Not Found', text: async () => '' });
116
+ await artifactsCommand(config, ['dep-missing'], chalk);
117
+ expect(process.exitCode).toBe(1);
118
+ const logged = console.error.mock.calls.map(c => c.join(' ')).join('\n');
119
+ expect(logged).toContain('No artifact found for dep-missing');
120
+ });
121
+
122
+ it('reports a generic error on a 500', async () => {
123
+ global.fetch = vi.fn().mockResolvedValue({ ok: false, status: 500, statusText: 'Internal Server Error', text: async () => 'boom' });
124
+ await artifactsCommand(config, ['dep-broken'], chalk);
125
+ expect(process.exitCode).toBe(1);
126
+ const logged = console.error.mock.calls.map(c => c.join(' ')).join('\n');
127
+ expect(logged).toContain('500');
128
+ expect(logged).toContain('boom');
129
+ });
130
+
131
+ it('reports a clear error when the network request itself fails', async () => {
132
+ global.fetch = vi.fn().mockRejectedValue(new Error('getaddrinfo ENOTFOUND'));
133
+ await artifactsCommand(config, ['dep-offline'], chalk);
134
+ expect(process.exitCode).toBe(1);
135
+ const logged = console.error.mock.calls.map(c => c.join(' ')).join('\n');
136
+ expect(logged).toContain('Could not reach Badgr');
137
+ });
138
+
139
+ it('downloads and extracts the artifact tarball, listing files', async () => {
140
+ global.fetch = vi.fn().mockResolvedValue({ ok: true, arrayBuffer: async () => new ArrayBuffer(8) });
141
+ await artifactsCommand(config, ['dep-abc', '--output', destDir], chalk);
142
+
143
+ expect(process.exitCode).toBeUndefined();
144
+ expect(fs.existsSync(path.join(destDir, 'test-results', 'trace.zip'))).toBe(true);
145
+ const logged = console.log.mock.calls.map(c => c.join(' ')).join('\n');
146
+ expect(logged).toContain('test-results/trace.zip');
147
+ expect(logged).toContain(destDir);
148
+ });
149
+
150
+ it('reports an empty artifact without erroring', async () => {
151
+ tarExtractBehavior = extractNothing;
152
+ global.fetch = vi.fn().mockResolvedValue({ ok: true, arrayBuffer: async () => new ArrayBuffer(8) });
153
+ await artifactsCommand(config, ['dep-empty', '--output', destDir], chalk);
154
+
155
+ expect(process.exitCode).toBeUndefined();
156
+ const logged = console.log.mock.calls.map(c => c.join(' ')).join('\n');
157
+ expect(logged).toContain('artifact was empty');
158
+ });
159
+
160
+ it('defaults to <CONFIG_DIR>/artifacts/<id> when --output is not given', async () => {
161
+ global.fetch = vi.fn().mockResolvedValue({ ok: true, arrayBuffer: async () => new ArrayBuffer(8) });
162
+ await artifactsCommand(config, ['dep-default-dir'], chalk);
163
+
164
+ expect(process.exitCode).toBeUndefined();
165
+ const logged = console.log.mock.calls.map(c => c.join(' ')).join('\n');
166
+ expect(logged).toContain(path.join(_fakeConfigDir, 'artifacts', 'dep-default-dir'));
167
+ });
168
+ });