badgr-cli 1.1.1 → 1.1.3

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 (73) hide show
  1. package/LICENSE +207 -0
  2. package/README.md +13 -6
  3. package/package.json +44 -2
  4. package/src/api.js +16 -0
  5. package/src/badgr.js +26 -10
  6. package/src/commands/batch.js +11 -0
  7. package/src/commands/billing.js +3 -3
  8. package/src/commands/comfyui.js +31 -15
  9. package/src/commands/connect.js +4 -1
  10. package/src/commands/diagnose.js +493 -0
  11. package/src/commands/embed.js +13 -10
  12. package/src/commands/job.js +246 -0
  13. package/src/commands/launch.js +152 -16
  14. package/src/commands/login.js +75 -20
  15. package/src/commands/run.js +74 -16
  16. package/src/commands/sbatch.js +6 -1
  17. package/src/commands/serve.js +44 -30
  18. package/src/commands/train.js +8 -12
  19. package/src/commands/transcribe.js +13 -10
  20. package/src/credentials.js +33 -0
  21. package/src/envFlag.js +10 -0
  22. package/src/fallback.js +13 -2
  23. package/src/onboarding.js +8 -1
  24. package/src/progress.js +48 -0
  25. package/src/commands/task.js +0 -25
  26. package/tests/agent-images.test.js +0 -17
  27. package/tests/api.test.js +0 -168
  28. package/tests/artifactDownload.test.js +0 -113
  29. package/tests/artifacts.test.js +0 -168
  30. package/tests/batch.test.js +0 -641
  31. package/tests/browser.test.js +0 -51
  32. package/tests/capacity.test.js +0 -68
  33. package/tests/commands.test.js +0 -417
  34. package/tests/config.test.js +0 -96
  35. package/tests/connect.test.js +0 -83
  36. package/tests/detect.test.js +0 -191
  37. package/tests/down.test.js +0 -150
  38. package/tests/errors.test.js +0 -130
  39. package/tests/fallback-timeout.test.js +0 -41
  40. package/tests/fanout.test.js +0 -124
  41. package/tests/gpu-doctor-classifiers.test.js +0 -402
  42. package/tests/gpu-doctor-doctor.test.js +0 -304
  43. package/tests/gpu-doctor-probe-cache.test.js +0 -110
  44. package/tests/gpu-doctor-probes.test.js +0 -257
  45. package/tests/heartbeat.test.js +0 -70
  46. package/tests/job-progress-poll.test.js +0 -136
  47. package/tests/launch-command-argv.test.js +0 -93
  48. package/tests/launch-readiness.test.js +0 -403
  49. package/tests/launch.test.js +0 -440
  50. package/tests/onboarding.test.js +0 -134
  51. package/tests/productized-dry-run.test.js +0 -141
  52. package/tests/productized-runners.test.js +0 -237
  53. package/tests/pull.test.js +0 -266
  54. package/tests/rerun.test.js +0 -94
  55. package/tests/restart.test.js +0 -88
  56. package/tests/router.test.js +0 -98
  57. package/tests/run-lifecycle.test.js +0 -1054
  58. package/tests/sbatch.test.js +0 -190
  59. package/tests/secrets.test.js +0 -16
  60. package/tests/serve-apps.test.js +0 -189
  61. package/tests/serve-lifecycle.test.js +0 -931
  62. package/tests/slurm.test.js +0 -77
  63. package/tests/spec.test.js +0 -201
  64. package/tests/status.test.js +0 -73
  65. package/tests/store.test.js +0 -187
  66. package/tests/task.test.js +0 -109
  67. package/tests/template.test.js +0 -556
  68. package/tests/train-lora-dataset.test.js +0 -176
  69. package/tests/upload.test.js +0 -79
  70. package/tests/workload-rerun.test.js +0 -56
  71. package/tests/workload-spec.test.js +0 -180
  72. package/tests/workload-templates.test.js +0 -865
  73. package/tests/workload-workspace-paths.test.js +0 -46
@@ -1,25 +0,0 @@
1
- import { launchCommand } from './launch.js';
2
-
3
- /**
4
- * badgr task "<description>" -- <command>
5
- *
6
- * The MVP version of "assign a task" — a thin label wrapper over
7
- * `badgr launch . -- <command>`. It does not queue, schedule, or track
8
- * anything beyond what `badgr launch` already does; the description is
9
- * printed for the human and otherwise discarded (the receipt/status page
10
- * still key off the deployment ID, same as any other launch).
11
- */
12
- export async function taskCommand(config, args, chalk) {
13
- const description = args[0];
14
- if (!description || description.startsWith('-')) {
15
- console.error(chalk.red('\nUsage: badgr task "<description>" [badgr launch flags...] -- <command>\n'));
16
- console.error(chalk.dim(' The description must come first, before any flags.'));
17
- console.error(chalk.dim(' Example: badgr task "Run the Chromium tests and tell me what failed" --max-cost 1 -- npm run test:chromium'));
18
- console.error(chalk.dim(' Example: badgr task "Fix the failing checkout test" --max-cost 1 -- claude -p "Fix the failing checkout test"\n'));
19
- process.exitCode = 1;
20
- return;
21
- }
22
-
23
- console.log(chalk.dim(` Task: ${description}`));
24
- return launchCommand(config, ['.', ...args.slice(1)], chalk);
25
- }
@@ -1,17 +0,0 @@
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
- });
package/tests/api.test.js DELETED
@@ -1,168 +0,0 @@
1
- import { describe, it, expect, vi, beforeEach } from 'vitest';
2
- import { callApi, listModels, chatCompletion, submitJob, getJobStatus, listReceipts, getReceipt, runJob, serveModel, uploadBlob } from '../src/api.js';
3
-
4
- const mockConfig = { apiKey: 'sk-test', baseUrl: 'https://api.test/v1', defaultModel: 'llama-3' };
5
-
6
- function mockFetch(data, ok = true, status = 200) {
7
- global.fetch = vi.fn().mockResolvedValue({
8
- ok, status,
9
- json: async () => data,
10
- text: async () => JSON.stringify(data),
11
- });
12
- }
13
-
14
- describe('callApi', () => {
15
- it('includes Authorization header', async () => {
16
- mockFetch({ data: [] });
17
- await callApi('/models', { apiKey: 'sk-abc', baseUrl: 'https://api.test/v1' });
18
- const [, init] = global.fetch.mock.calls[0];
19
- expect(init.headers.Authorization).toBe('Bearer sk-abc');
20
- });
21
-
22
- it('throws on non-ok response with status code', async () => {
23
- mockFetch('Unauthorized', false, 401);
24
- await expect(callApi('/models', { apiKey: 'bad', baseUrl: 'https://api.test/v1' })).rejects.toThrow('401');
25
- });
26
-
27
- it('sends JSON body for POST', async () => {
28
- mockFetch({ id: 'j1' });
29
- await callApi('/jobs', { method: 'POST', apiKey: 'sk-x', baseUrl: 'https://api.test/v1', body: { script: 'a.py' } });
30
- expect(JSON.parse(global.fetch.mock.calls[0][1].body)).toEqual({ script: 'a.py' });
31
- });
32
-
33
- it('omits body for GET', async () => {
34
- mockFetch({});
35
- await callApi('/models', { apiKey: 'x', baseUrl: 'https://api.test/v1' });
36
- expect(global.fetch.mock.calls[0][1].body).toBeUndefined();
37
- });
38
- });
39
-
40
- describe('chatCompletion', () => {
41
- it('POST /chat/completions with messages', async () => {
42
- mockFetch({ choices: [{ message: { content: 'hi' } }] });
43
- const msgs = [{ role: 'user', content: 'hello' }];
44
- await chatCompletion(mockConfig, msgs);
45
- const [url, init] = global.fetch.mock.calls[0];
46
- expect(url).toContain('/chat/completions');
47
- expect(JSON.parse(init.body).messages).toEqual(msgs);
48
- });
49
-
50
- it('uses defaultModel when no model option', async () => {
51
- mockFetch({ choices: [] });
52
- await chatCompletion(mockConfig, []);
53
- expect(JSON.parse(global.fetch.mock.calls[0][1].body).model).toBe('llama-3');
54
- });
55
-
56
- it('uses provided model over default', async () => {
57
- mockFetch({ choices: [] });
58
- await chatCompletion(mockConfig, [], { model: 'gpt-4o' });
59
- expect(JSON.parse(global.fetch.mock.calls[0][1].body).model).toBe('gpt-4o');
60
- });
61
- });
62
-
63
- describe('listModels', () => {
64
- it('calls GET /models', async () => {
65
- mockFetch({ data: [{ id: 'llama-3' }] });
66
- const result = await listModels(mockConfig);
67
- expect(global.fetch).toHaveBeenCalledWith(expect.stringContaining('/models'), expect.any(Object));
68
- expect(result.data[0].id).toBe('llama-3');
69
- });
70
- });
71
-
72
- describe('submitJob / getJobStatus', () => {
73
- it('POST /jobs with job body', async () => {
74
- mockFetch({ jobId: 'abc-123' });
75
- const result = await submitJob(mockConfig, { script: 'train.py', gpuType: 'rtx-4090' });
76
- expect(result.jobId).toBe('abc-123');
77
- });
78
-
79
- it('GET /jobs/:id', async () => {
80
- mockFetch({ status: 'running' });
81
- await getJobStatus(mockConfig, 'job-xyz');
82
- expect(global.fetch).toHaveBeenCalledWith(expect.stringContaining('/jobs/job-xyz'), expect.any(Object));
83
- });
84
- });
85
-
86
- describe('listReceipts', () => {
87
- it('calls GET /receipts', async () => {
88
- mockFetch({ object: 'list', data: [] });
89
- await listReceipts(mockConfig);
90
- const [url] = global.fetch.mock.calls[0];
91
- expect(url).toContain('/receipts');
92
- });
93
-
94
- it('appends limit query param', async () => {
95
- mockFetch({ object: 'list', data: [] });
96
- await listReceipts(mockConfig, { limit: 50 });
97
- const [url] = global.fetch.mock.calls[0];
98
- expect(url).toContain('limit=50');
99
- });
100
-
101
- it('appends status filter when provided', async () => {
102
- mockFetch({ object: 'list', data: [] });
103
- await listReceipts(mockConfig, { status: 'success' });
104
- const [url] = global.fetch.mock.calls[0];
105
- expect(url).toContain('status=success');
106
- });
107
- });
108
-
109
- describe('getReceipt', () => {
110
- it('calls GET /receipts/:id', async () => {
111
- mockFetch({ request_id: 'req-123', status: 'success' });
112
- const result = await getReceipt(mockConfig, 'req-123');
113
- expect(global.fetch).toHaveBeenCalledWith(expect.stringContaining('/receipts/req-123'), expect.any(Object));
114
- expect(result.request_id).toBe('req-123');
115
- });
116
- });
117
-
118
- describe('runJob', () => {
119
- it('POST /run with job spec', async () => {
120
- mockFetch({ deployment_id: 'dep-abc', status: 'running', provider: 'runpod' });
121
- const result = await runJob(mockConfig, { command: ['python', 'train.py'], gpu: 'A100' });
122
- const [url, init] = global.fetch.mock.calls[0];
123
- expect(url).toContain('/run');
124
- expect(init.method).toBe('POST');
125
- expect(JSON.parse(init.body).gpu).toBe('A100');
126
- expect(result.deployment_id).toBe('dep-abc');
127
- });
128
- });
129
-
130
- describe('serveModel', () => {
131
- it('POST /serve with model spec', async () => {
132
- mockFetch({ deployment_id: 'dep-xyz', status: 'running', endpoint_url: 'https://api.badgr.ai/v1' });
133
- const result = await serveModel(mockConfig, { model: 'meta-llama/Llama-3.1-8B-Instruct', gpu: 'L40S' });
134
- const [url, init] = global.fetch.mock.calls[0];
135
- expect(url).toContain('/serve');
136
- expect(init.method).toBe('POST');
137
- expect(JSON.parse(init.body).model).toBe('meta-llama/Llama-3.1-8B-Instruct');
138
- expect(result.deployment_id).toBe('dep-xyz');
139
- });
140
- });
141
-
142
- describe('uploadBlob', () => {
143
- it('POSTs multipart form data to /v1/uploads with Authorization, no manual Content-Type', async () => {
144
- mockFetch({ upload_id: 'up_1', code_uri: 'https://api.test/v1/uploads/up_1/download?token=t' });
145
- const result = await uploadBlob(mockConfig, { data: Buffer.from('hello'), filename: 'a.txt' });
146
- const [url, init] = global.fetch.mock.calls[0];
147
- expect(url).toBe('https://api.test/v1/uploads');
148
- expect(init.method).toBe('POST');
149
- expect(init.body).toBeInstanceOf(FormData);
150
- expect(init.headers.Authorization).toBe('Bearer sk-test');
151
- expect(init.headers['Content-Type']).toBeUndefined();
152
- expect(result.upload_id).toBe('up_1');
153
- expect(result.code_uri).toContain('up_1');
154
- });
155
-
156
- it('sets the Blob content type when contentType is given', async () => {
157
- mockFetch({ upload_id: 'up_2', code_uri: 'https://x/up_2' });
158
- await uploadBlob(mockConfig, { data: Buffer.from('x'), filename: 'a.tar.gz', contentType: 'application/gzip' });
159
- const [, init] = global.fetch.mock.calls[0];
160
- expect(init.body.get('file').type).toBe('application/gzip');
161
- });
162
-
163
- it('throws a clear error on a non-ok response', async () => {
164
- mockFetch('boom', false, 500);
165
- await expect(uploadBlob(mockConfig, { data: Buffer.from('x'), filename: 'a.txt' }))
166
- .rejects.toThrow(/Upload failed: 500/);
167
- });
168
- });
@@ -1,113 +0,0 @@
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
- });
@@ -1,168 +0,0 @@
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
- });