badgr-cli 1.0.36 → 1.0.37

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "badgr-cli",
3
- "version": "1.0.36",
3
+ "version": "1.0.37",
4
4
  "description": "Badgr, run or serve GPU workloads from one command",
5
5
  "type": "module",
6
6
  "bin": {
@@ -2,6 +2,15 @@ import { requireApiKey } from '../config.js';
2
2
  import { findDeployment, removeDeployment, addReceipt, generateReceiptId } from '../store.js';
3
3
  import { terminateDeployment, listDeployments } from '../api.js';
4
4
 
5
+ function formatRuntime(minutes) {
6
+ if (minutes < 60) return `${minutes}m`;
7
+ const days = Math.floor(minutes / 1440);
8
+ const hours = Math.floor((minutes % 1440) / 60);
9
+ const mins = minutes % 60;
10
+ if (days > 0) return `${days}d ${hours}h ${mins}m`;
11
+ return `${hours}h ${mins}m`;
12
+ }
13
+
5
14
  export async function downCommand(config, args, chalk) {
6
15
  const hasAll = args.includes('--all');
7
16
  const hasYes = args.includes('--yes') || args.includes('-y');
@@ -60,7 +69,7 @@ export async function downCommand(config, args, chalk) {
60
69
 
61
70
  console.log(chalk.green('\n✓ Stopped'));
62
71
  console.log(chalk.green(' Billing ended\n'));
63
- console.log(` ${chalk.bold('Runtime:')} ${runtimeMin}m`);
72
+ console.log(` ${chalk.bold('Runtime:')} ${formatRuntime(runtimeMin)}`);
64
73
  if (finalCost > 0) console.log(` ${chalk.bold('Final cost:')} $${finalCost.toFixed(4)}`);
65
74
  console.log(` ${chalk.bold('Receipt:')} ${chalk.dim(rcptId)}\n`);
66
75
  }
@@ -0,0 +1,128 @@
1
+ /**
2
+ * badgr down — receipt display and runtime formatting tests
3
+ */
4
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
5
+ import { downCommand } from '../src/commands/down.js';
6
+
7
+ vi.mock('../src/api.js', () => ({
8
+ terminateDeployment: vi.fn(),
9
+ listDeployments: vi.fn(),
10
+ }));
11
+
12
+ vi.mock('../src/store.js', () => ({
13
+ findDeployment: vi.fn(() => null),
14
+ removeDeployment: vi.fn(),
15
+ addReceipt: vi.fn(),
16
+ generateReceiptId: vi.fn(() => 'rcpt-test-001'),
17
+ }));
18
+
19
+ vi.mock('../src/config.js', () => ({
20
+ requireApiKey: vi.fn(),
21
+ }));
22
+
23
+ import * as api from '../src/api.js';
24
+ import * as store from '../src/store.js';
25
+
26
+ const config = { apiKey: 'sk-test', baseUrl: 'https://api.test/v1' };
27
+
28
+ const chalk = {
29
+ bold: s => s,
30
+ dim: s => s,
31
+ red: s => s,
32
+ yellow: s => s,
33
+ green: s => s,
34
+ cyan: s => s,
35
+ };
36
+
37
+ function makeStoppedDep(runtimeSeconds, costPerHour = 1.00) {
38
+ const now = Date.now() / 1000;
39
+ return {
40
+ deployment_id: 'dep-test-001',
41
+ gpu_type: 'L40S',
42
+ cost_per_hour: costPerHour,
43
+ stopped_at: now,
44
+ started_at: now - runtimeSeconds,
45
+ };
46
+ }
47
+
48
+ beforeEach(() => {
49
+ vi.spyOn(console, 'log').mockImplementation(() => {});
50
+ vi.spyOn(console, 'error').mockImplementation(() => {});
51
+ vi.spyOn(process.stdout, 'write').mockImplementation(() => true);
52
+ vi.resetAllMocks();
53
+ store.findDeployment.mockReturnValue(null);
54
+ store.generateReceiptId.mockReturnValue('rcpt-test-001');
55
+ });
56
+
57
+ afterEach(() => {
58
+ vi.restoreAllMocks();
59
+ });
60
+
61
+ describe('runtime display formatting', () => {
62
+ it('shows minutes only for short runs (< 1h)', async () => {
63
+ api.terminateDeployment.mockResolvedValue(makeStoppedDep(27 * 60, 0.34));
64
+ const lines = [];
65
+ console.log.mockImplementation((...args) => lines.push(args.join(' ')));
66
+
67
+ await downCommand(config, ['dep-test-001'], chalk);
68
+
69
+ const runtimeLine = lines.find(l => l.includes('Runtime:'));
70
+ expect(runtimeLine).toMatch(/27m/);
71
+ expect(runtimeLine).not.toMatch(/\dh/);
72
+ });
73
+
74
+ it('shows hours and minutes for runs >= 1h', async () => {
75
+ api.terminateDeployment.mockResolvedValue(makeStoppedDep(4091 * 60, 1.00));
76
+ const lines = [];
77
+ console.log.mockImplementation((...args) => lines.push(args.join(' ')));
78
+
79
+ await downCommand(config, ['dep-test-001'], chalk);
80
+
81
+ const runtimeLine = lines.find(l => l.includes('Runtime:'));
82
+ expect(runtimeLine).toMatch(/2d 20h 11m/);
83
+ expect(runtimeLine).not.toMatch(/4091m/);
84
+ });
85
+
86
+ it('shows days, hours, minutes for multi-day runs', async () => {
87
+ api.terminateDeployment.mockResolvedValue(makeStoppedDep(5722 * 60, 0.46));
88
+ const lines = [];
89
+ console.log.mockImplementation((...args) => lines.push(args.join(' ')));
90
+
91
+ await downCommand(config, ['dep-test-001'], chalk);
92
+
93
+ const runtimeLine = lines.find(l => l.includes('Runtime:'));
94
+ expect(runtimeLine).toMatch(/3d 23h 22m/);
95
+ expect(runtimeLine).not.toMatch(/5722m/);
96
+ });
97
+ });
98
+
99
+ describe('receipt is recorded', () => {
100
+ it('adds a receipt with correct fields on stop', async () => {
101
+ api.terminateDeployment.mockResolvedValue(makeStoppedDep(27 * 60, 0.34));
102
+
103
+ await downCommand(config, ['dep-test-001'], chalk);
104
+
105
+ expect(store.addReceipt).toHaveBeenCalledWith(expect.objectContaining({
106
+ receiptId: 'rcpt-test-001',
107
+ deploymentId: 'dep-test-001',
108
+ gpu: 'L40S',
109
+ status: 'terminated',
110
+ }));
111
+ const call = store.addReceipt.mock.calls[0][0];
112
+ expect(call.runtimeSeconds).toBeCloseTo(27 * 60, -1);
113
+ expect(call.finalCost).toBeGreaterThan(0);
114
+ });
115
+ });
116
+
117
+ describe('error handling', () => {
118
+ it('prints error and returns when terminateDeployment throws', async () => {
119
+ api.terminateDeployment.mockRejectedValue(new Error('network error'));
120
+ const errLines = [];
121
+ console.error.mockImplementation(msg => errLines.push(msg));
122
+
123
+ await downCommand(config, ['dep-test-001'], chalk);
124
+
125
+ expect(errLines.join('\n')).toMatch(/network error|Could not stop/i);
126
+ expect(store.addReceipt).not.toHaveBeenCalled();
127
+ });
128
+ });