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/launch.test.js
DELETED
|
@@ -1,440 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
2
|
-
|
|
3
|
-
const credentialStore = {};
|
|
4
|
-
vi.mock('../src/credentials.js', () => ({
|
|
5
|
-
getCredential: vi.fn(provider => credentialStore[provider] ?? null),
|
|
6
|
-
setCredential: vi.fn((provider, value) => { credentialStore[provider] = value; }),
|
|
7
|
-
PROVIDER_ENV_KEYS: { anthropic: 'ANTHROPIC_API_KEY', openai: 'OPENAI_API_KEY' },
|
|
8
|
-
}));
|
|
9
|
-
|
|
10
|
-
const mockPassword = vi.fn();
|
|
11
|
-
vi.mock('@inquirer/prompts', () => ({ password: (...args) => mockPassword(...args) }));
|
|
12
|
-
|
|
13
|
-
const { launchCommand, DEFAULT_LAUNCH_MAX_COST } = await import('../src/commands/launch.js');
|
|
14
|
-
|
|
15
|
-
const chalk = { red: s => s, dim: s => s, green: s => s, bold: s => s, yellow: s => s, cyan: s => s };
|
|
16
|
-
const config = { apiKey: 'test-key', baseUrl: 'https://example.test/v1' };
|
|
17
|
-
|
|
18
|
-
describe('launchCommand', () => {
|
|
19
|
-
let originalStdinTTY;
|
|
20
|
-
let originalStdoutTTY;
|
|
21
|
-
|
|
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(credentialStore)) delete credentialStore[k];
|
|
27
|
-
mockPassword.mockReset();
|
|
28
|
-
originalStdinTTY = process.stdin.isTTY;
|
|
29
|
-
originalStdoutTTY = process.stdout.isTTY;
|
|
30
|
-
});
|
|
31
|
-
|
|
32
|
-
afterEach(() => {
|
|
33
|
-
vi.restoreAllMocks();
|
|
34
|
-
process.exitCode = undefined;
|
|
35
|
-
process.stdin.isTTY = originalStdinTTY;
|
|
36
|
-
process.stdout.isTTY = originalStdoutTTY;
|
|
37
|
-
});
|
|
38
|
-
|
|
39
|
-
it('requires an agent, task, or source', async () => {
|
|
40
|
-
await launchCommand(config, [], chalk);
|
|
41
|
-
expect(process.exitCode).toBe(1);
|
|
42
|
-
});
|
|
43
|
-
|
|
44
|
-
describe('cline — Badgr-native, no credential required', () => {
|
|
45
|
-
it('resolves image and command with no connect step', async () => {
|
|
46
|
-
await launchCommand(config, ['cline', '--dry-run', 'Fix the checkout bug'], chalk);
|
|
47
|
-
expect(process.exitCode).toBeUndefined();
|
|
48
|
-
const logged = console.log.mock.calls.map(c => c.join(' ')).join('\n');
|
|
49
|
-
expect(logged).toContain("badgr-cline-run Fix the checkout bug");
|
|
50
|
-
expect(logged).toContain('ghcr.io/michaelmanly/badgr-agent-cline:latest');
|
|
51
|
-
expect(logged).not.toContain('Env:');
|
|
52
|
-
});
|
|
53
|
-
|
|
54
|
-
it('requires a task', async () => {
|
|
55
|
-
await launchCommand(config, ['cline'], chalk);
|
|
56
|
-
expect(process.exitCode).toBe(1);
|
|
57
|
-
});
|
|
58
|
-
});
|
|
59
|
-
|
|
60
|
-
describe('claude — bring-your-own Anthropic', () => {
|
|
61
|
-
it('requires a connected credential', async () => {
|
|
62
|
-
await launchCommand(config, ['claude', 'Fix the checkout bug'], chalk);
|
|
63
|
-
expect(process.exitCode).toBe(1);
|
|
64
|
-
const logged = console.error.mock.calls.map(c => c.join(' ')).join('\n');
|
|
65
|
-
expect(logged).toContain('badgr connect anthropic');
|
|
66
|
-
});
|
|
67
|
-
|
|
68
|
-
it('resolves image, command, and injects the stored credential once connected', async () => {
|
|
69
|
-
credentialStore.anthropic = 'sk-ant-fake';
|
|
70
|
-
await launchCommand(config, ['claude', '--dry-run', 'Fix the checkout bug'], chalk);
|
|
71
|
-
expect(process.exitCode).toBeUndefined();
|
|
72
|
-
const logged = console.log.mock.calls.map(c => c.join(' ')).join('\n');
|
|
73
|
-
expect(logged).toContain("claude -p Fix the checkout bug");
|
|
74
|
-
expect(logged).toContain('ghcr.io/michaelmanly/badgr-agent-claude:latest');
|
|
75
|
-
});
|
|
76
|
-
|
|
77
|
-
it('never prints the credential value — Env line is redacted', async () => {
|
|
78
|
-
credentialStore.anthropic = 'sk-ant-super-secret';
|
|
79
|
-
await launchCommand(config, ['claude', '--dry-run', 'Fix the checkout bug'], chalk);
|
|
80
|
-
const logged = console.log.mock.calls.map(c => c.join(' ')).join('\n');
|
|
81
|
-
expect(logged).not.toContain('sk-ant-super-secret');
|
|
82
|
-
expect(logged).toContain('ANTHROPIC_API_KEY=<redacted>');
|
|
83
|
-
});
|
|
84
|
-
|
|
85
|
-
it('an explicit --env wins over a stored credential for the same key (no duplicate, no override)', async () => {
|
|
86
|
-
credentialStore.anthropic = 'sk-ant-stored';
|
|
87
|
-
await launchCommand(config, [
|
|
88
|
-
'claude', '--dry-run', '--env', 'ANTHROPIC_API_KEY=sk-ant-explicit', 'Fix the checkout bug',
|
|
89
|
-
], chalk);
|
|
90
|
-
expect(process.exitCode).toBeUndefined();
|
|
91
|
-
const logged = console.log.mock.calls.map(c => c.join(' ')).join('\n');
|
|
92
|
-
expect((logged.match(/ANTHROPIC_API_KEY=/g) || []).length).toBe(1);
|
|
93
|
-
});
|
|
94
|
-
|
|
95
|
-
it('an already-set --env credential skips the connect requirement', async () => {
|
|
96
|
-
await launchCommand(config, [
|
|
97
|
-
'claude', '--dry-run', '--env', 'ANTHROPIC_API_KEY=sk-ant-from-env', 'Fix the checkout bug',
|
|
98
|
-
], chalk);
|
|
99
|
-
expect(process.exitCode).toBeUndefined();
|
|
100
|
-
});
|
|
101
|
-
|
|
102
|
-
it('requires a task', async () => {
|
|
103
|
-
credentialStore.anthropic = 'sk-ant-fake';
|
|
104
|
-
await launchCommand(config, ['claude'], chalk);
|
|
105
|
-
expect(process.exitCode).toBe(1);
|
|
106
|
-
});
|
|
107
|
-
});
|
|
108
|
-
|
|
109
|
-
describe('codex — bring-your-own OpenAI', () => {
|
|
110
|
-
it('requires a connected credential', async () => {
|
|
111
|
-
await launchCommand(config, ['codex', 'Write tests'], chalk);
|
|
112
|
-
expect(process.exitCode).toBe(1);
|
|
113
|
-
const logged = console.error.mock.calls.map(c => c.join(' ')).join('\n');
|
|
114
|
-
expect(logged).toContain('badgr connect openai');
|
|
115
|
-
});
|
|
116
|
-
|
|
117
|
-
it('resolves image and command once connected', async () => {
|
|
118
|
-
credentialStore.openai = 'sk-fake';
|
|
119
|
-
await launchCommand(config, ['codex', '--dry-run', 'Write tests'], chalk);
|
|
120
|
-
expect(process.exitCode).toBeUndefined();
|
|
121
|
-
const logged = console.log.mock.calls.map(c => c.join(' ')).join('\n');
|
|
122
|
-
expect(logged).toContain("badgr-codex-run Write tests");
|
|
123
|
-
expect(logged).toContain('ghcr.io/michaelmanly/badgr-agent-codex:latest');
|
|
124
|
-
});
|
|
125
|
-
});
|
|
126
|
-
|
|
127
|
-
describe('inline credential prompt — interactive terminal, missing credential', () => {
|
|
128
|
-
beforeEach(() => {
|
|
129
|
-
process.stdin.isTTY = true;
|
|
130
|
-
process.stdout.isTTY = true;
|
|
131
|
-
});
|
|
132
|
-
|
|
133
|
-
it('prompts for the key, stores it, and continues the same launch — no separate badgr connect + rerun needed', async () => {
|
|
134
|
-
mockPassword.mockResolvedValue('sk-ant-typed-inline');
|
|
135
|
-
await launchCommand(config, ['claude', '--dry-run', 'Fix the checkout bug'], chalk);
|
|
136
|
-
expect(process.exitCode).toBeUndefined();
|
|
137
|
-
expect(mockPassword).toHaveBeenCalledTimes(1);
|
|
138
|
-
expect(credentialStore.anthropic).toBe('sk-ant-typed-inline');
|
|
139
|
-
const logged = console.log.mock.calls.map(c => c.join(' ')).join('\n');
|
|
140
|
-
expect(logged).toContain("claude -p Fix the checkout bug");
|
|
141
|
-
expect(logged).not.toContain('sk-ant-typed-inline'); // redacted, not just stored
|
|
142
|
-
});
|
|
143
|
-
|
|
144
|
-
it('same inline prompt for codex/OpenAI', async () => {
|
|
145
|
-
mockPassword.mockResolvedValue('sk-openai-typed-inline');
|
|
146
|
-
await launchCommand(config, ['codex', '--dry-run', 'Write tests'], chalk);
|
|
147
|
-
expect(process.exitCode).toBeUndefined();
|
|
148
|
-
expect(credentialStore.openai).toBe('sk-openai-typed-inline');
|
|
149
|
-
});
|
|
150
|
-
|
|
151
|
-
it('fails cleanly if the user cancels the prompt (Ctrl+C) instead of hanging', async () => {
|
|
152
|
-
mockPassword.mockRejectedValue(new Error('User force closed the prompt'));
|
|
153
|
-
await launchCommand(config, ['claude', '--dry-run', 'Fix the checkout bug'], chalk);
|
|
154
|
-
expect(process.exitCode).toBe(1);
|
|
155
|
-
expect(credentialStore.anthropic).toBeUndefined();
|
|
156
|
-
});
|
|
157
|
-
|
|
158
|
-
it('fails cleanly on an empty key instead of storing it', async () => {
|
|
159
|
-
mockPassword.mockResolvedValue(' ');
|
|
160
|
-
await launchCommand(config, ['claude', '--dry-run', 'Fix the checkout bug'], chalk);
|
|
161
|
-
expect(process.exitCode).toBe(1);
|
|
162
|
-
expect(credentialStore.anthropic).toBeUndefined();
|
|
163
|
-
});
|
|
164
|
-
|
|
165
|
-
it('does not prompt at all once a credential is already stored', async () => {
|
|
166
|
-
credentialStore.anthropic = 'sk-ant-already-stored';
|
|
167
|
-
await launchCommand(config, ['claude', '--dry-run', 'Fix the checkout bug'], chalk);
|
|
168
|
-
expect(mockPassword).not.toHaveBeenCalled();
|
|
169
|
-
});
|
|
170
|
-
});
|
|
171
|
-
|
|
172
|
-
describe('playwright — no credential, task is a display label only', () => {
|
|
173
|
-
it('resolves image and the default test command with no task given', async () => {
|
|
174
|
-
await launchCommand(config, ['playwright', '--dry-run'], chalk);
|
|
175
|
-
expect(process.exitCode).toBeUndefined();
|
|
176
|
-
const logged = console.log.mock.calls.map(c => c.join(' ')).join('\n');
|
|
177
|
-
expect(logged).toContain('npx playwright test --reporter=line,html --trace=retain-on-failure');
|
|
178
|
-
expect(logged).toContain('ghcr.io/michaelmanly/badgr-agent-playwright:latest');
|
|
179
|
-
// The only env Badgr sets automatically is the stable HTML report dir
|
|
180
|
-
// (makes report output deterministic) — no credentials involved.
|
|
181
|
-
expect(logged).toContain('PLAYWRIGHT_HTML_OUTPUT_DIR=playwright-report');
|
|
182
|
-
});
|
|
183
|
-
|
|
184
|
-
it('accepts a task string but does not change the command it runs', async () => {
|
|
185
|
-
await launchCommand(config, ['playwright', '--dry-run', 'Test the checkout flow'], chalk);
|
|
186
|
-
expect(process.exitCode).toBeUndefined();
|
|
187
|
-
const logged = console.log.mock.calls.map(c => c.join(' ')).join('\n');
|
|
188
|
-
expect(logged).toContain('npx playwright test');
|
|
189
|
-
expect(logged).not.toContain('Test the checkout flow --');
|
|
190
|
-
});
|
|
191
|
-
|
|
192
|
-
it('automatically declares playwright-report/test-results as artifacts', async () => {
|
|
193
|
-
await launchCommand(config, ['playwright', '--dry-run'], chalk);
|
|
194
|
-
const logged = console.log.mock.calls.map(c => c.join(' ')).join('\n');
|
|
195
|
-
expect(logged).toContain('Artifacts:');
|
|
196
|
-
expect(logged).toContain('playwright-report, test-results');
|
|
197
|
-
});
|
|
198
|
-
|
|
199
|
-
it('an explicit --artifacts overrides the automatic default instead of merging with it', async () => {
|
|
200
|
-
await launchCommand(config, ['playwright', '--dry-run', '--artifacts', 'custom-report'], chalk);
|
|
201
|
-
const logged = console.log.mock.calls.map(c => c.join(' ')).join('\n');
|
|
202
|
-
const artifactsLine = logged.split('\n').find(l => l.includes('Artifacts:'));
|
|
203
|
-
expect(logged).toContain('custom-report');
|
|
204
|
-
expect(artifactsLine).not.toContain('playwright-report');
|
|
205
|
-
});
|
|
206
|
-
|
|
207
|
-
it('never requires badgr connect', async () => {
|
|
208
|
-
await launchCommand(config, ['playwright', '--dry-run'], chalk);
|
|
209
|
-
expect(process.exitCode).toBeUndefined();
|
|
210
|
-
});
|
|
211
|
-
|
|
212
|
-
it('applies the default max-cost like the other shorthand workloads', async () => {
|
|
213
|
-
await launchCommand(config, ['playwright', '--dry-run'], chalk);
|
|
214
|
-
const logged = console.log.mock.calls.map(c => c.join(' ')).join('\n');
|
|
215
|
-
expect(logged).toMatch(new RegExp(`Max cost:\\s*\\$${DEFAULT_LAUNCH_MAX_COST}\\b`));
|
|
216
|
-
});
|
|
217
|
-
});
|
|
218
|
-
|
|
219
|
-
describe('VM class — deterministic workload → size default, --size override', () => {
|
|
220
|
-
it('defaults cline/claude/codex to the small VM class', async () => {
|
|
221
|
-
await launchCommand(config, ['cline', '--dry-run', 'Fix the checkout bug'], chalk);
|
|
222
|
-
const logged = console.log.mock.calls.map(c => c.join(' ')).join('\n');
|
|
223
|
-
expect(logged).toContain('VM class:');
|
|
224
|
-
expect(logged).toContain('small (2 vCPU, 4 GB RAM)');
|
|
225
|
-
});
|
|
226
|
-
|
|
227
|
-
it('defaults playwright to the browser VM class', async () => {
|
|
228
|
-
await launchCommand(config, ['playwright', '--dry-run'], chalk);
|
|
229
|
-
const logged = console.log.mock.calls.map(c => c.join(' ')).join('\n');
|
|
230
|
-
expect(logged).toContain('browser (4 vCPU, 8 GB RAM)');
|
|
231
|
-
});
|
|
232
|
-
|
|
233
|
-
it('--size overrides the default for an agent workload', async () => {
|
|
234
|
-
credentialStore.anthropic = 'sk-ant-fake';
|
|
235
|
-
await launchCommand(config, ['claude', '--dry-run', '--size', 'medium', 'Run the full suite'], chalk);
|
|
236
|
-
const logged = console.log.mock.calls.map(c => c.join(' ')).join('\n');
|
|
237
|
-
expect(logged).toContain('medium (4 vCPU, 8 GB RAM)');
|
|
238
|
-
});
|
|
239
|
-
|
|
240
|
-
it('--size overrides the default for playwright', async () => {
|
|
241
|
-
await launchCommand(config, ['playwright', '--dry-run', '--size', 'medium'], chalk);
|
|
242
|
-
const logged = console.log.mock.calls.map(c => c.join(' ')).join('\n');
|
|
243
|
-
expect(logged).toContain('medium (4 vCPU, 8 GB RAM)');
|
|
244
|
-
expect(logged).not.toContain('browser (');
|
|
245
|
-
});
|
|
246
|
-
|
|
247
|
-
it('rejects an unknown --size value', async () => {
|
|
248
|
-
await launchCommand(config, ['cline', '--dry-run', '--size', 'huge', 'Fix the checkout bug'], chalk);
|
|
249
|
-
expect(process.exitCode).toBe(1);
|
|
250
|
-
const logged = console.error.mock.calls.map(c => c.join(' ')).join('\n');
|
|
251
|
-
expect(logged).toContain('Unknown --size "huge"');
|
|
252
|
-
});
|
|
253
|
-
|
|
254
|
-
it('defaults the explicit form to the small VM class too', async () => {
|
|
255
|
-
await launchCommand(config, ['.', '--max-cost', '1', '--dry-run', '--', 'npm', 'test'], chalk);
|
|
256
|
-
const logged = console.log.mock.calls.map(c => c.join(' ')).join('\n');
|
|
257
|
-
expect(logged).toContain('small (2 vCPU, 4 GB RAM)');
|
|
258
|
-
});
|
|
259
|
-
});
|
|
260
|
-
|
|
261
|
-
describe('task-text safety — flags must never be reinterpreted from the task', () => {
|
|
262
|
-
it('a flag-shaped word inside the task reaches the agent unchanged', async () => {
|
|
263
|
-
await launchCommand(config, ['cline', '--dry-run', 'Fix', 'the', '--output', 'bug'], chalk);
|
|
264
|
-
expect(process.exitCode).toBeUndefined();
|
|
265
|
-
const logged = console.log.mock.calls.map(c => c.join(' ')).join('\n');
|
|
266
|
-
expect(logged).toContain("badgr-cline-run Fix the --output bug");
|
|
267
|
-
expect(logged).not.toContain('Output:');
|
|
268
|
-
});
|
|
269
|
-
|
|
270
|
-
it('$vars, backticks, double quotes, and newlines in the task are preserved verbatim (no embedded single quote — see entrypoint.py tests for the full shlex round-trip including one)', async () => {
|
|
271
|
-
const task = 'Fix the bug with $DANGER and `whoami` and "double" and\nnewline';
|
|
272
|
-
await launchCommand(config, ['cline', '--dry-run', task], chalk);
|
|
273
|
-
expect(process.exitCode).toBeUndefined();
|
|
274
|
-
const logged = console.log.mock.calls.map(c => c.join(' ')).join('\n');
|
|
275
|
-
expect(logged).toContain(task);
|
|
276
|
-
});
|
|
277
|
-
|
|
278
|
-
it('warns (non-blocking) when a flag-named token appears after the task instead of before it, without disabling a --dry-run that was already recognized', async () => {
|
|
279
|
-
await launchCommand(config, ['cline', '--dry-run', 'Fix checkout', '--max-cost', '5'], chalk);
|
|
280
|
-
expect(process.exitCode).toBeUndefined();
|
|
281
|
-
const errLogged = console.error.mock.calls.map(c => c.join(' ')).join('\n');
|
|
282
|
-
expect(errLogged).toContain('appears inside the task text');
|
|
283
|
-
const out = console.log.mock.calls.map(c => c.join(' ')).join('\n');
|
|
284
|
-
expect(out).toContain("badgr-cline-run Fix checkout --max-cost 5");
|
|
285
|
-
// The --max-cost that got swallowed into the task must NOT apply as the real cap.
|
|
286
|
-
expect(out).toMatch(new RegExp(`Max cost:\\s*\\$${DEFAULT_LAUNCH_MAX_COST}\\b`));
|
|
287
|
-
});
|
|
288
|
-
|
|
289
|
-
it('flags placed before the task are parsed normally, not swallowed', async () => {
|
|
290
|
-
await launchCommand(config, ['cline', '--max-cost', '9', '--dry-run', 'Fix checkout'], chalk);
|
|
291
|
-
expect(process.exitCode).toBeUndefined();
|
|
292
|
-
const logged = console.log.mock.calls.map(c => c.join(' ')).join('\n');
|
|
293
|
-
expect(logged).toContain("badgr-cline-run Fix checkout");
|
|
294
|
-
expect(logged).toMatch(/Max cost:\s*\$9/);
|
|
295
|
-
});
|
|
296
|
-
});
|
|
297
|
-
|
|
298
|
-
describe('rejects --gpu (CPU-only)', () => {
|
|
299
|
-
it('for the agent shorthand', async () => {
|
|
300
|
-
await launchCommand(config, ['cline', '--gpu', 'A100', 'Fix checkout'], chalk);
|
|
301
|
-
expect(process.exitCode).toBe(1);
|
|
302
|
-
});
|
|
303
|
-
|
|
304
|
-
it('for the explicit form', async () => {
|
|
305
|
-
await launchCommand(config, ['.', '--gpu', 'A100', '--', 'npm', 'test'], chalk);
|
|
306
|
-
expect(process.exitCode).toBe(1);
|
|
307
|
-
});
|
|
308
|
-
});
|
|
309
|
-
|
|
310
|
-
describe('default max-cost', () => {
|
|
311
|
-
it('applies the $2 default and annotates it as a default', async () => {
|
|
312
|
-
await launchCommand(config, ['cline', '--dry-run', 'Fix checkout'], chalk);
|
|
313
|
-
const logged = console.log.mock.calls.map(c => c.join(' ')).join('\n');
|
|
314
|
-
expect(logged).toMatch(new RegExp(`Max cost:\\s*\\$${DEFAULT_LAUNCH_MAX_COST}\\b`));
|
|
315
|
-
expect(logged).toContain('default — use --max-cost N to override');
|
|
316
|
-
});
|
|
317
|
-
|
|
318
|
-
it('an explicit --max-cost overrides the default', async () => {
|
|
319
|
-
await launchCommand(config, ['cline', '--dry-run', '--max-cost', '9', 'Fix checkout'], chalk);
|
|
320
|
-
const logged = console.log.mock.calls.map(c => c.join(' ')).join('\n');
|
|
321
|
-
expect(logged).toMatch(/Max cost:\s*\$9/);
|
|
322
|
-
expect(logged).not.toContain('default —');
|
|
323
|
-
});
|
|
324
|
-
});
|
|
325
|
-
|
|
326
|
-
describe('explicit form — badgr launch <source> -- <command> (advanced escape hatch)', () => {
|
|
327
|
-
it('rejects an empty command after --', async () => {
|
|
328
|
-
await launchCommand(config, ['.', '--'], chalk);
|
|
329
|
-
expect(process.exitCode).toBe(1);
|
|
330
|
-
});
|
|
331
|
-
|
|
332
|
-
it('rejects both -- <command> and --cmd together', async () => {
|
|
333
|
-
await launchCommand(config, ['.', '--cmd', 'python x.py', '--', 'npm', 'test'], chalk);
|
|
334
|
-
expect(process.exitCode).toBe(1);
|
|
335
|
-
});
|
|
336
|
-
|
|
337
|
-
it('requires a command via -- or --cmd', async () => {
|
|
338
|
-
await launchCommand(config, ['https://github.com/user/repo'], chalk);
|
|
339
|
-
expect(process.exitCode).toBe(1);
|
|
340
|
-
});
|
|
341
|
-
|
|
342
|
-
it('accepts -- passthrough and forwards a CPU dry run for an arbitrary command', async () => {
|
|
343
|
-
await launchCommand(config, ['.', '--dry-run', '--max-cost', '1', '--', 'npm', 'test'], chalk);
|
|
344
|
-
expect(process.exitCode).toBeUndefined();
|
|
345
|
-
const logged = console.log.mock.calls.map(c => c.join(' ')).join('\n');
|
|
346
|
-
expect(logged).toContain('Compute:');
|
|
347
|
-
expect(logged).toContain('CPU VM');
|
|
348
|
-
expect(logged).toContain('npm test');
|
|
349
|
-
});
|
|
350
|
-
|
|
351
|
-
it('accepts a quoted --cmd form', async () => {
|
|
352
|
-
await launchCommand(config, ['.', '--dry-run', '--cmd', 'python eval.py'], chalk);
|
|
353
|
-
expect(process.exitCode).toBeUndefined();
|
|
354
|
-
const logged = console.log.mock.calls.map(c => c.join(' ')).join('\n');
|
|
355
|
-
expect(logged).toContain('python eval.py');
|
|
356
|
-
});
|
|
357
|
-
|
|
358
|
-
it('forwards repeated --artifacts paths through to the dry-run banner', async () => {
|
|
359
|
-
await launchCommand(config, [
|
|
360
|
-
'.', '--dry-run', '--max-cost', '1',
|
|
361
|
-
'--artifacts', 'playwright-report', '--artifacts', 'test-results',
|
|
362
|
-
'--', 'npx', 'playwright', 'test',
|
|
363
|
-
], chalk);
|
|
364
|
-
expect(process.exitCode).toBeUndefined();
|
|
365
|
-
const logged = console.log.mock.calls.map(c => c.join(' ')).join('\n');
|
|
366
|
-
expect(logged).toContain('Artifacts:');
|
|
367
|
-
expect(logged).toContain('playwright-report, test-results');
|
|
368
|
-
});
|
|
369
|
-
|
|
370
|
-
it('warns when --env carries a secret-shaped key (shell history exposure) and redacts its value', async () => {
|
|
371
|
-
await launchCommand(config, [
|
|
372
|
-
'.', '--dry-run', '--max-cost', '1',
|
|
373
|
-
'--env', 'ANTHROPIC_API_KEY=sk-ant-fake',
|
|
374
|
-
'--', 'npm', 'test',
|
|
375
|
-
], chalk);
|
|
376
|
-
expect(process.exitCode).toBeUndefined();
|
|
377
|
-
const logged = console.log.mock.calls.map(c => c.join(' ')).join('\n');
|
|
378
|
-
expect(logged).toContain('ANTHROPIC_API_KEY=<redacted>');
|
|
379
|
-
expect(logged).not.toContain('sk-ant-fake');
|
|
380
|
-
expect(logged).toContain('shell history');
|
|
381
|
-
});
|
|
382
|
-
|
|
383
|
-
it('accepts a GitHub URL as the source', async () => {
|
|
384
|
-
await launchCommand(config, ['https://github.com/user/repo', '--dry-run', '--max-cost', '1', '--', 'python', 'narrgo.py'], chalk);
|
|
385
|
-
expect(process.exitCode).toBeUndefined();
|
|
386
|
-
const logged = console.log.mock.calls.map(c => c.join(' ')).join('\n');
|
|
387
|
-
expect(logged).toContain('https://github.com/user/repo');
|
|
388
|
-
});
|
|
389
|
-
|
|
390
|
-
it('rejects unexpected extra positional arguments after the source', async () => {
|
|
391
|
-
await launchCommand(config, ['.', 'extra-arg', '--dry-run', '--max-cost', '1', '--', 'npm', 'test'], chalk);
|
|
392
|
-
expect(process.exitCode).toBe(1);
|
|
393
|
-
const logged = console.error.mock.calls.map(c => c.join(' ')).join('\n');
|
|
394
|
-
expect(logged).toContain('extra-arg');
|
|
395
|
-
});
|
|
396
|
-
|
|
397
|
-
it('forwards --image, --region, and --max-runtime through to the dry-run banner', async () => {
|
|
398
|
-
await launchCommand(config, [
|
|
399
|
-
'.', '--dry-run', '--max-cost', '1',
|
|
400
|
-
'--image', 'ghcr.io/aibadgr/custom:latest', '--region', 'eu', '--max-runtime', '30',
|
|
401
|
-
'--', 'npm', 'test',
|
|
402
|
-
], chalk);
|
|
403
|
-
expect(process.exitCode).toBeUndefined();
|
|
404
|
-
const logged = console.log.mock.calls.map(c => c.join(' ')).join('\n');
|
|
405
|
-
expect(logged).toContain('ghcr.io/aibadgr/custom:latest');
|
|
406
|
-
expect(logged).toContain('30min');
|
|
407
|
-
});
|
|
408
|
-
|
|
409
|
-
it('--no-detach is accepted without erroring on a dry run', async () => {
|
|
410
|
-
await launchCommand(config, ['.', '--dry-run', '--max-cost', '1', '--no-detach', '--', 'npm', 'test'], chalk);
|
|
411
|
-
expect(process.exitCode).toBeUndefined();
|
|
412
|
-
});
|
|
413
|
-
|
|
414
|
-
it('applies the default max-cost when not specified', async () => {
|
|
415
|
-
await launchCommand(config, ['.', '--dry-run', '--', 'npm', 'test'], chalk);
|
|
416
|
-
expect(process.exitCode).toBeUndefined();
|
|
417
|
-
const logged = console.log.mock.calls.map(c => c.join(' ')).join('\n');
|
|
418
|
-
expect(logged).toMatch(new RegExp(`Max cost:\\s*\\$${DEFAULT_LAUNCH_MAX_COST}\\b`));
|
|
419
|
-
});
|
|
420
|
-
|
|
421
|
-
describe('flag order — everything after -- is passthrough, not parsed', () => {
|
|
422
|
-
it('a flag placed AFTER -- is swallowed into the command, not applied as a Badgr flag', async () => {
|
|
423
|
-
await launchCommand(config, ['.', '--dry-run', '--', 'npm', 'test', '--max-cost', '1'], chalk);
|
|
424
|
-
expect(process.exitCode).toBeUndefined();
|
|
425
|
-
const logged = console.log.mock.calls.map(c => c.join(' ')).join('\n');
|
|
426
|
-
expect(logged).toContain('npm test --max-cost 1');
|
|
427
|
-
expect(logged).not.toMatch(/Max cost:\s*\$1\b/);
|
|
428
|
-
});
|
|
429
|
-
|
|
430
|
-
it('the same flag placed BEFORE -- is correctly applied as the spend cap', async () => {
|
|
431
|
-
await launchCommand(config, ['.', '--max-cost', '1', '--dry-run', '--', 'npm', 'test'], chalk);
|
|
432
|
-
expect(process.exitCode).toBeUndefined();
|
|
433
|
-
const logged = console.log.mock.calls.map(c => c.join(' ')).join('\n');
|
|
434
|
-
expect(logged).toContain('npm test');
|
|
435
|
-
expect(logged).not.toContain('npm test --max-cost 1');
|
|
436
|
-
expect(logged).toMatch(/Max cost:\s*\$1\b/);
|
|
437
|
-
});
|
|
438
|
-
});
|
|
439
|
-
});
|
|
440
|
-
});
|
package/tests/onboarding.test.js
DELETED
|
@@ -1,134 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
2
|
-
|
|
3
|
-
const mockCallApi = vi.fn();
|
|
4
|
-
const mockOpenBrowser = vi.fn();
|
|
5
|
-
const mockSaveConfig = vi.fn(updates => ({ apiKey: 'test-key', baseUrl: 'https://example.test/v1', ...updates }));
|
|
6
|
-
|
|
7
|
-
vi.mock('../src/api.js', () => ({ callApi: (...args) => mockCallApi(...args) }));
|
|
8
|
-
vi.mock('../src/browser.js', () => ({ openBrowser: (...args) => mockOpenBrowser(...args) }));
|
|
9
|
-
vi.mock('../src/config.js', async () => {
|
|
10
|
-
const actual = await vi.importActual('../src/config.js');
|
|
11
|
-
return { ...actual, saveConfig: (...args) => mockSaveConfig(...args) };
|
|
12
|
-
});
|
|
13
|
-
|
|
14
|
-
const { ensureBadgrReady } = await import('../src/onboarding.js');
|
|
15
|
-
|
|
16
|
-
const chalk = { red: s => s, dim: s => s, green: s => s, bold: s => s, yellow: s => s, cyan: s => s };
|
|
17
|
-
|
|
18
|
-
describe('ensureBadgrReady', () => {
|
|
19
|
-
let originalStdinTTY;
|
|
20
|
-
let originalStdoutTTY;
|
|
21
|
-
|
|
22
|
-
beforeEach(() => {
|
|
23
|
-
mockCallApi.mockReset();
|
|
24
|
-
mockOpenBrowser.mockReset();
|
|
25
|
-
mockSaveConfig.mockClear();
|
|
26
|
-
process.exitCode = undefined;
|
|
27
|
-
vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
28
|
-
vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
29
|
-
originalStdinTTY = process.stdin.isTTY;
|
|
30
|
-
originalStdoutTTY = process.stdout.isTTY;
|
|
31
|
-
});
|
|
32
|
-
|
|
33
|
-
afterEach(() => {
|
|
34
|
-
vi.restoreAllMocks();
|
|
35
|
-
vi.useRealTimers();
|
|
36
|
-
process.stdin.isTTY = originalStdinTTY;
|
|
37
|
-
process.stdout.isTTY = originalStdoutTTY;
|
|
38
|
-
process.exitCode = undefined;
|
|
39
|
-
});
|
|
40
|
-
|
|
41
|
-
describe('non-interactive (no TTY)', () => {
|
|
42
|
-
beforeEach(() => {
|
|
43
|
-
process.stdin.isTTY = false;
|
|
44
|
-
process.stdout.isTTY = false;
|
|
45
|
-
});
|
|
46
|
-
|
|
47
|
-
it('returns the config unchanged when an API key is already stored, without any network calls', async () => {
|
|
48
|
-
const config = { apiKey: 'existing-key', baseUrl: 'https://example.test/v1' };
|
|
49
|
-
const result = await ensureBadgrReady(config, chalk);
|
|
50
|
-
expect(result).toBe(config);
|
|
51
|
-
expect(mockCallApi).not.toHaveBeenCalled();
|
|
52
|
-
expect(mockOpenBrowser).not.toHaveBeenCalled();
|
|
53
|
-
});
|
|
54
|
-
|
|
55
|
-
it('throws the standard "Run: badgr login" error instead of opening a browser', async () => {
|
|
56
|
-
const config = { apiKey: undefined, baseUrl: 'https://example.test/v1' };
|
|
57
|
-
await expect(ensureBadgrReady(config, chalk)).rejects.toThrow(/badgr login/);
|
|
58
|
-
expect(mockOpenBrowser).not.toHaveBeenCalled();
|
|
59
|
-
});
|
|
60
|
-
});
|
|
61
|
-
|
|
62
|
-
describe('interactive (TTY present)', () => {
|
|
63
|
-
beforeEach(() => {
|
|
64
|
-
process.stdin.isTTY = true;
|
|
65
|
-
process.stdout.isTTY = true;
|
|
66
|
-
});
|
|
67
|
-
|
|
68
|
-
it('skips the login/billing flow entirely when already logged in with a positive balance', async () => {
|
|
69
|
-
const config = { apiKey: 'existing-key', baseUrl: 'https://example.test/v1' };
|
|
70
|
-
mockCallApi.mockResolvedValueOnce({ credits: 10 }); // /api/me
|
|
71
|
-
const result = await ensureBadgrReady(config, chalk);
|
|
72
|
-
expect(result.apiKey).toBe('existing-key');
|
|
73
|
-
expect(mockOpenBrowser).not.toHaveBeenCalled();
|
|
74
|
-
});
|
|
75
|
-
|
|
76
|
-
it('opens the login URL, polls until complete, and stores the returned API key', async () => {
|
|
77
|
-
vi.useFakeTimers();
|
|
78
|
-
const config = { apiKey: undefined, baseUrl: 'https://example.test/v1' };
|
|
79
|
-
|
|
80
|
-
mockCallApi
|
|
81
|
-
.mockResolvedValueOnce({ session_id: 'sess-1', login_url: 'https://aibadgr.com/login?cli_session=sess-1' }) // POST /cli/session
|
|
82
|
-
.mockResolvedValueOnce({ status: 'pending' }) // first poll
|
|
83
|
-
.mockResolvedValueOnce({ status: 'complete', api_key: 'new-key-from-browser', credits: 10 }) // second poll
|
|
84
|
-
.mockResolvedValueOnce({ credits: 10 }); // /api/me funded check afterward
|
|
85
|
-
|
|
86
|
-
const promise = ensureBadgrReady(config, chalk);
|
|
87
|
-
await vi.advanceTimersByTimeAsync(2000);
|
|
88
|
-
await vi.advanceTimersByTimeAsync(2000);
|
|
89
|
-
const result = await promise;
|
|
90
|
-
|
|
91
|
-
expect(mockOpenBrowser).toHaveBeenCalledWith('https://aibadgr.com/login?cli_session=sess-1');
|
|
92
|
-
expect(mockSaveConfig).toHaveBeenCalledWith({ apiKey: 'new-key-from-browser' });
|
|
93
|
-
expect(result.apiKey).toBe('new-key-from-browser');
|
|
94
|
-
});
|
|
95
|
-
|
|
96
|
-
it('throws if the login session expires before completion', async () => {
|
|
97
|
-
vi.useFakeTimers();
|
|
98
|
-
const config = { apiKey: undefined, baseUrl: 'https://example.test/v1' };
|
|
99
|
-
mockCallApi
|
|
100
|
-
.mockResolvedValueOnce({ session_id: 'sess-1', login_url: 'https://aibadgr.com/login?cli_session=sess-1' })
|
|
101
|
-
.mockResolvedValueOnce({ status: 'expired' });
|
|
102
|
-
|
|
103
|
-
const promise = ensureBadgrReady(config, chalk);
|
|
104
|
-
const assertion = expect(promise).rejects.toThrow(/expired/);
|
|
105
|
-
await vi.advanceTimersByTimeAsync(2000);
|
|
106
|
-
await assertion;
|
|
107
|
-
});
|
|
108
|
-
|
|
109
|
-
it('opens billing checkout and polls until funded when balance is $0', async () => {
|
|
110
|
-
vi.useFakeTimers();
|
|
111
|
-
const config = { apiKey: 'existing-key', baseUrl: 'https://example.test/v1' };
|
|
112
|
-
|
|
113
|
-
mockCallApi
|
|
114
|
-
.mockResolvedValueOnce({ credits: 0 }) // initial /api/me
|
|
115
|
-
.mockResolvedValueOnce({ credits: 0 }) // first poll
|
|
116
|
-
.mockResolvedValueOnce({ credits: 10 }); // second poll — funded
|
|
117
|
-
|
|
118
|
-
const promise = ensureBadgrReady(config, chalk);
|
|
119
|
-
await vi.advanceTimersByTimeAsync(2000);
|
|
120
|
-
await vi.advanceTimersByTimeAsync(2000);
|
|
121
|
-
await promise;
|
|
122
|
-
|
|
123
|
-
expect(mockOpenBrowser).toHaveBeenCalledWith('https://aibadgr.com/dashboard#billing');
|
|
124
|
-
});
|
|
125
|
-
|
|
126
|
-
it('does not block the launch if the balance check itself fails to reach the API', async () => {
|
|
127
|
-
const config = { apiKey: 'existing-key', baseUrl: 'https://example.test/v1' };
|
|
128
|
-
mockCallApi.mockRejectedValueOnce(new Error('network down'));
|
|
129
|
-
const result = await ensureBadgrReady(config, chalk);
|
|
130
|
-
expect(result.apiKey).toBe('existing-key');
|
|
131
|
-
expect(mockOpenBrowser).not.toHaveBeenCalled();
|
|
132
|
-
});
|
|
133
|
-
});
|
|
134
|
-
});
|