rafcode 2.1.1 → 2.2.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.
- package/.claude/settings.local.json +4 -1
- package/CLAUDE.md +59 -11
- package/RAF/ahslfe-config-wizard/decisions.md +34 -0
- package/RAF/ahslfe-config-wizard/input.md +1 -0
- package/RAF/ahslfe-config-wizard/outcomes/01-define-config-schema.md +38 -0
- package/RAF/ahslfe-config-wizard/outcomes/02-refactor-codebase-to-use-config.md +67 -0
- package/RAF/ahslfe-config-wizard/outcomes/03-create-config-documentation.md +37 -0
- package/RAF/ahslfe-config-wizard/outcomes/04-implement-raf-config-command.md +47 -0
- package/RAF/ahslfe-config-wizard/outcomes/05-update-claude-md.md +26 -0
- package/RAF/ahslfe-config-wizard/plans/01-define-config-schema.md +73 -0
- package/RAF/ahslfe-config-wizard/plans/02-refactor-codebase-to-use-config.md +74 -0
- package/RAF/ahslfe-config-wizard/plans/03-create-config-documentation.md +57 -0
- package/RAF/ahslfe-config-wizard/plans/04-implement-raf-config-command.md +66 -0
- package/RAF/ahslfe-config-wizard/plans/05-update-claude-md.md +60 -0
- package/RAF/ahstvo-token-tracker/decisions.md +44 -0
- package/RAF/ahstvo-token-tracker/input.md +3 -0
- package/RAF/ahstvo-token-tracker/outcomes/01-full-model-id-support.md +43 -0
- package/RAF/ahstvo-token-tracker/outcomes/02-name-generation-no-session.md +33 -0
- package/RAF/ahstvo-token-tracker/outcomes/03-unify-stream-json-execution.md +48 -0
- package/RAF/ahstvo-token-tracker/outcomes/04-token-tracking-cost-calculation.md +53 -0
- package/RAF/ahstvo-token-tracker/outcomes/05-token-cost-console-reporting.md +57 -0
- package/RAF/ahstvo-token-tracker/outcomes/06-runtime-verbose-toggle.md +53 -0
- package/RAF/ahstvo-token-tracker/outcomes/07-readme-config-docs.md +36 -0
- package/RAF/ahstvo-token-tracker/plans/01-full-model-id-support.md +35 -0
- package/RAF/ahstvo-token-tracker/plans/02-name-generation-no-session.md +36 -0
- package/RAF/ahstvo-token-tracker/plans/03-unify-stream-json-execution.md +44 -0
- package/RAF/ahstvo-token-tracker/plans/04-token-tracking-cost-calculation.md +56 -0
- package/RAF/ahstvo-token-tracker/plans/05-token-cost-console-reporting.md +55 -0
- package/RAF/ahstvo-token-tracker/plans/06-runtime-verbose-toggle.md +48 -0
- package/RAF/ahstvo-token-tracker/plans/07-readme-config-docs.md +44 -0
- package/README.md +34 -0
- package/dist/commands/config.d.ts +3 -0
- package/dist/commands/config.d.ts.map +1 -0
- package/dist/commands/config.js +173 -0
- package/dist/commands/config.js.map +1 -0
- package/dist/commands/do.d.ts.map +1 -1
- package/dist/commands/do.js +47 -6
- package/dist/commands/do.js.map +1 -1
- package/dist/commands/plan.d.ts.map +1 -1
- package/dist/commands/plan.js +3 -2
- package/dist/commands/plan.js.map +1 -1
- package/dist/core/claude-runner.d.ts +19 -2
- package/dist/core/claude-runner.d.ts.map +1 -1
- package/dist/core/claude-runner.js +43 -96
- package/dist/core/claude-runner.js.map +1 -1
- package/dist/core/failure-analyzer.d.ts.map +1 -1
- package/dist/core/failure-analyzer.js +6 -3
- package/dist/core/failure-analyzer.js.map +1 -1
- package/dist/core/git.d.ts.map +1 -1
- package/dist/core/git.js +10 -3
- package/dist/core/git.js.map +1 -1
- package/dist/core/pull-request.d.ts +1 -1
- package/dist/core/pull-request.d.ts.map +1 -1
- package/dist/core/pull-request.js +7 -4
- package/dist/core/pull-request.js.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/parsers/stream-renderer.d.ts +16 -1
- package/dist/parsers/stream-renderer.d.ts.map +1 -1
- package/dist/parsers/stream-renderer.js +34 -4
- package/dist/parsers/stream-renderer.js.map +1 -1
- package/dist/prompts/execution.d.ts.map +1 -1
- package/dist/prompts/execution.js +11 -1
- package/dist/prompts/execution.js.map +1 -1
- package/dist/types/config.d.ts +95 -4
- package/dist/types/config.d.ts.map +1 -1
- package/dist/types/config.js +63 -3
- package/dist/types/config.js.map +1 -1
- package/dist/utils/config.d.ts +59 -7
- package/dist/utils/config.d.ts.map +1 -1
- package/dist/utils/config.js +276 -21
- package/dist/utils/config.js.map +1 -1
- package/dist/utils/name-generator.d.ts +3 -7
- package/dist/utils/name-generator.d.ts.map +1 -1
- package/dist/utils/name-generator.js +75 -61
- package/dist/utils/name-generator.js.map +1 -1
- package/dist/utils/terminal-symbols.d.ts +21 -0
- package/dist/utils/terminal-symbols.d.ts.map +1 -1
- package/dist/utils/terminal-symbols.js +62 -0
- package/dist/utils/terminal-symbols.js.map +1 -1
- package/dist/utils/token-tracker.d.ts +45 -0
- package/dist/utils/token-tracker.d.ts.map +1 -0
- package/dist/utils/token-tracker.js +107 -0
- package/dist/utils/token-tracker.js.map +1 -0
- package/dist/utils/validation.d.ts +5 -5
- package/dist/utils/validation.d.ts.map +1 -1
- package/dist/utils/validation.js +10 -6
- package/dist/utils/validation.js.map +1 -1
- package/dist/utils/verbose-toggle.d.ts +33 -0
- package/dist/utils/verbose-toggle.d.ts.map +1 -0
- package/dist/utils/verbose-toggle.js +94 -0
- package/dist/utils/verbose-toggle.js.map +1 -0
- package/package.json +1 -1
- package/src/commands/config.ts +204 -0
- package/src/commands/do.ts +56 -5
- package/src/commands/plan.ts +3 -2
- package/src/core/claude-runner.ts +59 -115
- package/src/core/failure-analyzer.ts +6 -3
- package/src/core/git.ts +10 -3
- package/src/core/pull-request.ts +7 -4
- package/src/index.ts +2 -0
- package/src/parsers/stream-renderer.ts +54 -4
- package/src/prompts/config-docs.md +331 -0
- package/src/prompts/execution.ts +13 -1
- package/src/types/config.ts +156 -7
- package/src/utils/config.ts +335 -21
- package/src/utils/name-generator.ts +84 -71
- package/src/utils/terminal-symbols.ts +68 -0
- package/src/utils/token-tracker.ts +135 -0
- package/src/utils/validation.ts +15 -10
- package/src/utils/verbose-toggle.ts +103 -0
- package/tests/unit/claude-runner.test.ts +171 -7
- package/tests/unit/config-command.test.ts +163 -0
- package/tests/unit/config.test.ts +608 -30
- package/tests/unit/name-generator.test.ts +99 -75
- package/tests/unit/pull-request.test.ts +2 -0
- package/tests/unit/stream-renderer.test.ts +83 -0
- package/tests/unit/terminal-symbols.test.ts +157 -0
- package/tests/unit/token-tracker.test.ts +352 -0
- package/tests/unit/verbose-toggle.test.ts +204 -0
|
@@ -1,13 +1,35 @@
|
|
|
1
1
|
import { jest } from '@jest/globals';
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
2
|
+
import { EventEmitter } from 'node:events';
|
|
3
|
+
|
|
4
|
+
// Helper to create a mock spawn that returns a fake ChildProcess
|
|
5
|
+
function createMockSpawn(stdoutData: string | null, exitCode: number = 0) {
|
|
6
|
+
const stdout = new EventEmitter();
|
|
7
|
+
const stderr = new EventEmitter();
|
|
8
|
+
const proc = new EventEmitter() as any;
|
|
9
|
+
proc.stdout = stdout;
|
|
10
|
+
proc.stderr = stderr;
|
|
11
|
+
proc.kill = jest.fn();
|
|
12
|
+
|
|
13
|
+
// Schedule data emission and close after spawn is called
|
|
14
|
+
setTimeout(() => {
|
|
15
|
+
if (stdoutData !== null) {
|
|
16
|
+
stdout.emit('data', Buffer.from(stdoutData));
|
|
17
|
+
}
|
|
18
|
+
proc.emit('close', exitCode);
|
|
19
|
+
}, 0);
|
|
20
|
+
|
|
21
|
+
return proc;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Mock spawn before importing the module
|
|
25
|
+
const mockSpawn = jest.fn();
|
|
5
26
|
jest.unstable_mockModule('node:child_process', () => ({
|
|
6
|
-
|
|
27
|
+
spawn: mockSpawn,
|
|
28
|
+
execSync: jest.fn(), // keep available for transitive imports
|
|
7
29
|
}));
|
|
8
30
|
|
|
9
31
|
// Import after mocking
|
|
10
|
-
const { generateProjectName, generateProjectNames, sanitizeGeneratedName
|
|
32
|
+
const { generateProjectName, generateProjectNames, sanitizeGeneratedName } =
|
|
11
33
|
await import('../../src/utils/name-generator.js');
|
|
12
34
|
|
|
13
35
|
describe('Name Generator', () => {
|
|
@@ -16,55 +38,63 @@ describe('Name Generator', () => {
|
|
|
16
38
|
});
|
|
17
39
|
|
|
18
40
|
describe('generateProjectName', () => {
|
|
19
|
-
it('should return sanitized name from
|
|
20
|
-
|
|
41
|
+
it('should return sanitized name from Claude response', async () => {
|
|
42
|
+
mockSpawn.mockReturnValue(createMockSpawn('user-auth-system\n'));
|
|
21
43
|
|
|
22
44
|
const result = await generateProjectName('Build a user authentication system');
|
|
23
45
|
|
|
24
46
|
expect(result).toBe('user-auth-system');
|
|
25
|
-
expect(
|
|
26
|
-
expect(
|
|
27
|
-
|
|
47
|
+
expect(mockSpawn).toHaveBeenCalledTimes(1);
|
|
48
|
+
expect(mockSpawn).toHaveBeenCalledWith(
|
|
49
|
+
'claude',
|
|
50
|
+
expect.arrayContaining(['--model', 'haiku', '--no-session-persistence', '-p']),
|
|
28
51
|
expect.any(Object)
|
|
29
52
|
);
|
|
30
53
|
});
|
|
31
54
|
|
|
32
|
-
it('should
|
|
33
|
-
|
|
55
|
+
it('should pass --no-session-persistence flag', async () => {
|
|
56
|
+
mockSpawn.mockReturnValue(createMockSpawn('test-name\n'));
|
|
57
|
+
|
|
58
|
+
await generateProjectName('Test project');
|
|
59
|
+
|
|
60
|
+
const args = mockSpawn.mock.calls[0][1] as string[];
|
|
61
|
+
expect(args).toContain('--no-session-persistence');
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('should sanitize response with quotes', async () => {
|
|
65
|
+
mockSpawn.mockReturnValue(createMockSpawn('"api-rate-limiter"'));
|
|
34
66
|
|
|
35
67
|
const result = await generateProjectName('Create an API rate limiting service');
|
|
36
68
|
|
|
37
69
|
expect(result).toBe('api-rate-limiter');
|
|
38
70
|
});
|
|
39
71
|
|
|
40
|
-
it('should sanitize
|
|
41
|
-
|
|
72
|
+
it('should sanitize response with special characters', async () => {
|
|
73
|
+
mockSpawn.mockReturnValue(createMockSpawn('Some Project! Name'));
|
|
42
74
|
|
|
43
75
|
const result = await generateProjectName('Some project description');
|
|
44
76
|
|
|
45
77
|
expect(result).toBe('some-project-name');
|
|
46
78
|
});
|
|
47
79
|
|
|
48
|
-
it('should fall back to word extraction when
|
|
49
|
-
|
|
50
|
-
throw new Error('Command failed');
|
|
51
|
-
});
|
|
80
|
+
it('should fall back to word extraction when Claude fails', async () => {
|
|
81
|
+
mockSpawn.mockReturnValue(createMockSpawn(null, 1));
|
|
52
82
|
|
|
53
83
|
const result = await generateProjectName('Build a user authentication system with OAuth');
|
|
54
84
|
|
|
55
85
|
expect(result).toBe('build-user-authentication');
|
|
56
86
|
});
|
|
57
87
|
|
|
58
|
-
it('should fall back to word extraction when
|
|
59
|
-
|
|
88
|
+
it('should fall back to word extraction when Claude returns empty', async () => {
|
|
89
|
+
mockSpawn.mockReturnValue(createMockSpawn(''));
|
|
60
90
|
|
|
61
91
|
const result = await generateProjectName('Implement caching layer for database');
|
|
62
92
|
|
|
63
93
|
expect(result).toBe('implement-caching-layer');
|
|
64
94
|
});
|
|
65
95
|
|
|
66
|
-
it('should fall back to word extraction when
|
|
67
|
-
|
|
96
|
+
it('should fall back to word extraction when Claude returns single char', async () => {
|
|
97
|
+
mockSpawn.mockReturnValue(createMockSpawn('a'));
|
|
68
98
|
|
|
69
99
|
const result = await generateProjectName('Add new logging functionality');
|
|
70
100
|
|
|
@@ -72,47 +102,60 @@ describe('Name Generator', () => {
|
|
|
72
102
|
});
|
|
73
103
|
|
|
74
104
|
it('should return "project" when description has no meaningful words', async () => {
|
|
75
|
-
|
|
76
|
-
throw new Error('Command failed');
|
|
77
|
-
});
|
|
105
|
+
mockSpawn.mockReturnValue(createMockSpawn(null, 1));
|
|
78
106
|
|
|
79
107
|
const result = await generateProjectName('a b c');
|
|
80
108
|
|
|
81
109
|
expect(result).toBe('project');
|
|
82
110
|
});
|
|
83
111
|
|
|
84
|
-
it('should truncate long names from
|
|
112
|
+
it('should truncate long names from Claude', async () => {
|
|
85
113
|
const longName =
|
|
86
114
|
'this-is-a-very-long-project-name-that-exceeds-the-maximum-allowed-length-for-folder-names';
|
|
87
|
-
|
|
115
|
+
mockSpawn.mockReturnValue(createMockSpawn(longName));
|
|
88
116
|
|
|
89
117
|
const result = await generateProjectName('Some project');
|
|
90
118
|
|
|
91
119
|
expect(result.length).toBeLessThanOrEqual(50);
|
|
92
120
|
});
|
|
93
121
|
|
|
94
|
-
it('should handle multiline
|
|
95
|
-
|
|
122
|
+
it('should handle multiline response', async () => {
|
|
123
|
+
mockSpawn.mockReturnValue(createMockSpawn('project-name\nSome extra explanation\n'));
|
|
96
124
|
|
|
97
125
|
const result = await generateProjectName('Some project');
|
|
98
126
|
|
|
99
|
-
// Should take
|
|
127
|
+
// Should take full trimmed output
|
|
100
128
|
expect(result).toBe('project-name-some-extra-explanation');
|
|
101
129
|
});
|
|
102
130
|
|
|
103
131
|
it('should convert uppercase to lowercase', async () => {
|
|
104
|
-
|
|
132
|
+
mockSpawn.mockReturnValue(createMockSpawn('API-Gateway-Service'));
|
|
105
133
|
|
|
106
134
|
const result = await generateProjectName('Build an API gateway');
|
|
107
135
|
|
|
108
136
|
expect(result).toBe('api-gateway-service');
|
|
109
137
|
});
|
|
138
|
+
|
|
139
|
+
it('should handle spawn error gracefully', async () => {
|
|
140
|
+
const proc = new EventEmitter() as any;
|
|
141
|
+
proc.stdout = new EventEmitter();
|
|
142
|
+
proc.stderr = new EventEmitter();
|
|
143
|
+
proc.kill = jest.fn();
|
|
144
|
+
setTimeout(() => {
|
|
145
|
+
proc.emit('error', new Error('ENOENT'));
|
|
146
|
+
}, 0);
|
|
147
|
+
mockSpawn.mockReturnValue(proc);
|
|
148
|
+
|
|
149
|
+
const result = await generateProjectName('Build something');
|
|
150
|
+
|
|
151
|
+
expect(result).toBe('build-something');
|
|
152
|
+
});
|
|
110
153
|
});
|
|
111
154
|
|
|
112
155
|
describe('generateProjectNames', () => {
|
|
113
|
-
it('should return multiple sanitized names from
|
|
114
|
-
|
|
115
|
-
'phoenix-rise\nturbo-boost\nbug-squasher\ncatalyst\nmerlin\n'
|
|
156
|
+
it('should return multiple sanitized names from Claude response', async () => {
|
|
157
|
+
mockSpawn.mockReturnValue(
|
|
158
|
+
createMockSpawn('phoenix-rise\nturbo-boost\nbug-squasher\ncatalyst\nmerlin\n')
|
|
116
159
|
);
|
|
117
160
|
|
|
118
161
|
const result = await generateProjectNames('Build a user authentication system');
|
|
@@ -124,16 +167,15 @@ describe('Name Generator', () => {
|
|
|
124
167
|
'catalyst',
|
|
125
168
|
'merlin',
|
|
126
169
|
]);
|
|
127
|
-
expect(
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
);
|
|
170
|
+
expect(mockSpawn).toHaveBeenCalledTimes(1);
|
|
171
|
+
// Verify the prompt contains the multi-name generation prompt
|
|
172
|
+
const promptArg = (mockSpawn.mock.calls[0][1] as string[]).at(-1);
|
|
173
|
+
expect(promptArg).toContain('Generate 5 creative project names');
|
|
132
174
|
});
|
|
133
175
|
|
|
134
176
|
it('should handle names with numbering prefixes', async () => {
|
|
135
|
-
|
|
136
|
-
'1. phoenix-rise\n2. turbo-boost\n3. bug-squasher\n4. catalyst\n5. merlin\n'
|
|
177
|
+
mockSpawn.mockReturnValue(
|
|
178
|
+
createMockSpawn('1. phoenix-rise\n2. turbo-boost\n3. bug-squasher\n4. catalyst\n5. merlin\n')
|
|
137
179
|
);
|
|
138
180
|
|
|
139
181
|
const result = await generateProjectNames('Some project');
|
|
@@ -148,8 +190,8 @@ describe('Name Generator', () => {
|
|
|
148
190
|
});
|
|
149
191
|
|
|
150
192
|
it('should handle names with colon prefixes', async () => {
|
|
151
|
-
|
|
152
|
-
'1: phoenix-rise\n2: turbo-boost\n3: bug-squasher\n'
|
|
193
|
+
mockSpawn.mockReturnValue(
|
|
194
|
+
createMockSpawn('1: phoenix-rise\n2: turbo-boost\n3: bug-squasher\n')
|
|
153
195
|
);
|
|
154
196
|
|
|
155
197
|
const result = await generateProjectNames('Some project');
|
|
@@ -158,8 +200,8 @@ describe('Name Generator', () => {
|
|
|
158
200
|
});
|
|
159
201
|
|
|
160
202
|
it('should remove duplicate names', async () => {
|
|
161
|
-
|
|
162
|
-
'phoenix\nturbo-boost\nphoenix\ncatalyst\nturbo-boost\n'
|
|
203
|
+
mockSpawn.mockReturnValue(
|
|
204
|
+
createMockSpawn('phoenix\nturbo-boost\nphoenix\ncatalyst\nturbo-boost\n')
|
|
163
205
|
);
|
|
164
206
|
|
|
165
207
|
const result = await generateProjectNames('Some project');
|
|
@@ -168,8 +210,8 @@ describe('Name Generator', () => {
|
|
|
168
210
|
});
|
|
169
211
|
|
|
170
212
|
it('should limit to 5 names maximum', async () => {
|
|
171
|
-
|
|
172
|
-
'name-one\nname-two\nname-three\nname-four\nname-five\nname-six\nname-seven\n'
|
|
213
|
+
mockSpawn.mockReturnValue(
|
|
214
|
+
createMockSpawn('name-one\nname-two\nname-three\nname-four\nname-five\nname-six\nname-seven\n')
|
|
173
215
|
);
|
|
174
216
|
|
|
175
217
|
const result = await generateProjectNames('Some project');
|
|
@@ -178,17 +220,15 @@ describe('Name Generator', () => {
|
|
|
178
220
|
});
|
|
179
221
|
|
|
180
222
|
it('should return 3+ names when available', async () => {
|
|
181
|
-
|
|
223
|
+
mockSpawn.mockReturnValue(createMockSpawn('phoenix\nturbo-boost\ncatalyst\n'));
|
|
182
224
|
|
|
183
225
|
const result = await generateProjectNames('Some project');
|
|
184
226
|
|
|
185
227
|
expect(result.length).toBe(3);
|
|
186
228
|
});
|
|
187
229
|
|
|
188
|
-
it('should fall back to single name when
|
|
189
|
-
|
|
190
|
-
throw new Error('Command failed');
|
|
191
|
-
});
|
|
230
|
+
it('should fall back to single name when Claude fails', async () => {
|
|
231
|
+
mockSpawn.mockReturnValue(createMockSpawn(null, 1));
|
|
192
232
|
|
|
193
233
|
const result = await generateProjectNames('Build a user authentication system with OAuth');
|
|
194
234
|
|
|
@@ -196,7 +236,7 @@ describe('Name Generator', () => {
|
|
|
196
236
|
});
|
|
197
237
|
|
|
198
238
|
it('should fall back to single name when too few names returned', async () => {
|
|
199
|
-
|
|
239
|
+
mockSpawn.mockReturnValue(createMockSpawn('phoenix\nturbo\n'));
|
|
200
240
|
|
|
201
241
|
const result = await generateProjectNames('Build something awesome');
|
|
202
242
|
|
|
@@ -205,7 +245,9 @@ describe('Name Generator', () => {
|
|
|
205
245
|
});
|
|
206
246
|
|
|
207
247
|
it('should filter out invalid/short names', async () => {
|
|
208
|
-
|
|
248
|
+
mockSpawn.mockReturnValue(
|
|
249
|
+
createMockSpawn('phoenix\na\nturbo-boost\nb\ncatalyst\n')
|
|
250
|
+
);
|
|
209
251
|
|
|
210
252
|
const result = await generateProjectNames('Some project');
|
|
211
253
|
|
|
@@ -213,8 +255,8 @@ describe('Name Generator', () => {
|
|
|
213
255
|
});
|
|
214
256
|
|
|
215
257
|
it('should sanitize names with special characters', async () => {
|
|
216
|
-
|
|
217
|
-
'Phoenix Rise!\nTurbo-Boost!!!\nBug Squasher\n'
|
|
258
|
+
mockSpawn.mockReturnValue(
|
|
259
|
+
createMockSpawn('Phoenix Rise!\nTurbo-Boost!!!\nBug Squasher\n')
|
|
218
260
|
);
|
|
219
261
|
|
|
220
262
|
const result = await generateProjectNames('Some project');
|
|
@@ -223,7 +265,7 @@ describe('Name Generator', () => {
|
|
|
223
265
|
});
|
|
224
266
|
|
|
225
267
|
it('should handle empty response', async () => {
|
|
226
|
-
|
|
268
|
+
mockSpawn.mockReturnValue(createMockSpawn(''));
|
|
227
269
|
|
|
228
270
|
const result = await generateProjectNames('Some project');
|
|
229
271
|
|
|
@@ -262,22 +304,4 @@ describe('Name Generator', () => {
|
|
|
262
304
|
expect(result?.length).toBeLessThanOrEqual(50);
|
|
263
305
|
});
|
|
264
306
|
});
|
|
265
|
-
|
|
266
|
-
describe('escapeShellArg', () => {
|
|
267
|
-
it('should escape double quotes', () => {
|
|
268
|
-
expect(escapeShellArg('hello "world"')).toBe('hello \\"world\\"');
|
|
269
|
-
});
|
|
270
|
-
|
|
271
|
-
it('should escape backslashes', () => {
|
|
272
|
-
expect(escapeShellArg('hello\\world')).toBe('hello\\\\world');
|
|
273
|
-
});
|
|
274
|
-
|
|
275
|
-
it('should escape dollar signs', () => {
|
|
276
|
-
expect(escapeShellArg('$HOME')).toBe('\\$HOME');
|
|
277
|
-
});
|
|
278
|
-
|
|
279
|
-
it('should escape backticks', () => {
|
|
280
|
-
expect(escapeShellArg('`whoami`')).toBe('\\`whoami\\`');
|
|
281
|
-
});
|
|
282
|
-
});
|
|
283
307
|
});
|
|
@@ -25,8 +25,10 @@ jest.unstable_mockModule('node:fs', () => ({
|
|
|
25
25
|
|
|
26
26
|
// Mock os
|
|
27
27
|
const mockTmpdir = jest.fn().mockReturnValue('/tmp');
|
|
28
|
+
const mockHomedir = jest.fn().mockReturnValue('/home/testuser');
|
|
28
29
|
jest.unstable_mockModule('node:os', () => ({
|
|
29
30
|
tmpdir: mockTmpdir,
|
|
31
|
+
homedir: mockHomedir,
|
|
30
32
|
}));
|
|
31
33
|
|
|
32
34
|
// Mock logger to prevent console output
|
|
@@ -235,6 +235,89 @@ describe('renderStreamEvent', () => {
|
|
|
235
235
|
expect(result.display).toBe('');
|
|
236
236
|
expect(result.textContent).toBe('');
|
|
237
237
|
});
|
|
238
|
+
|
|
239
|
+
it('should extract usage data from result event', () => {
|
|
240
|
+
const line = JSON.stringify({
|
|
241
|
+
type: 'result',
|
|
242
|
+
subtype: 'success',
|
|
243
|
+
result: 'Done',
|
|
244
|
+
usage: {
|
|
245
|
+
input_tokens: 1000,
|
|
246
|
+
output_tokens: 500,
|
|
247
|
+
cache_read_input_tokens: 200,
|
|
248
|
+
cache_creation_input_tokens: 100,
|
|
249
|
+
},
|
|
250
|
+
modelUsage: {
|
|
251
|
+
'claude-opus-4-6': {
|
|
252
|
+
inputTokens: 1000,
|
|
253
|
+
outputTokens: 500,
|
|
254
|
+
cacheReadInputTokens: 200,
|
|
255
|
+
cacheCreationInputTokens: 100,
|
|
256
|
+
},
|
|
257
|
+
},
|
|
258
|
+
});
|
|
259
|
+
const result = renderStreamEvent(line);
|
|
260
|
+
expect(result.usageData).toBeDefined();
|
|
261
|
+
expect(result.usageData!.inputTokens).toBe(1000);
|
|
262
|
+
expect(result.usageData!.outputTokens).toBe(500);
|
|
263
|
+
expect(result.usageData!.cacheReadInputTokens).toBe(200);
|
|
264
|
+
expect(result.usageData!.cacheCreationInputTokens).toBe(100);
|
|
265
|
+
expect(result.usageData!.modelUsage['claude-opus-4-6']).toEqual({
|
|
266
|
+
inputTokens: 1000,
|
|
267
|
+
outputTokens: 500,
|
|
268
|
+
cacheReadInputTokens: 200,
|
|
269
|
+
cacheCreationInputTokens: 100,
|
|
270
|
+
});
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
it('should return undefined usageData when no usage in result', () => {
|
|
274
|
+
const line = JSON.stringify({
|
|
275
|
+
type: 'result',
|
|
276
|
+
subtype: 'success',
|
|
277
|
+
result: 'Done',
|
|
278
|
+
});
|
|
279
|
+
const result = renderStreamEvent(line);
|
|
280
|
+
expect(result.usageData).toBeUndefined();
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
it('should handle partial usage data gracefully', () => {
|
|
284
|
+
const line = JSON.stringify({
|
|
285
|
+
type: 'result',
|
|
286
|
+
usage: { input_tokens: 500 },
|
|
287
|
+
});
|
|
288
|
+
const result = renderStreamEvent(line);
|
|
289
|
+
expect(result.usageData).toBeDefined();
|
|
290
|
+
expect(result.usageData!.inputTokens).toBe(500);
|
|
291
|
+
expect(result.usageData!.outputTokens).toBe(0);
|
|
292
|
+
expect(result.usageData!.cacheReadInputTokens).toBe(0);
|
|
293
|
+
expect(result.usageData!.cacheCreationInputTokens).toBe(0);
|
|
294
|
+
expect(result.usageData!.modelUsage).toEqual({});
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
it('should handle multi-model usage data', () => {
|
|
298
|
+
const line = JSON.stringify({
|
|
299
|
+
type: 'result',
|
|
300
|
+
usage: {
|
|
301
|
+
input_tokens: 2000,
|
|
302
|
+
output_tokens: 800,
|
|
303
|
+
},
|
|
304
|
+
modelUsage: {
|
|
305
|
+
'claude-opus-4-6': {
|
|
306
|
+
inputTokens: 1500,
|
|
307
|
+
outputTokens: 600,
|
|
308
|
+
},
|
|
309
|
+
'claude-haiku-4-5-20251001': {
|
|
310
|
+
inputTokens: 500,
|
|
311
|
+
outputTokens: 200,
|
|
312
|
+
},
|
|
313
|
+
},
|
|
314
|
+
});
|
|
315
|
+
const result = renderStreamEvent(line);
|
|
316
|
+
expect(result.usageData).toBeDefined();
|
|
317
|
+
expect(Object.keys(result.usageData!.modelUsage)).toHaveLength(2);
|
|
318
|
+
expect(result.usageData!.modelUsage['claude-opus-4-6'].inputTokens).toBe(1500);
|
|
319
|
+
expect(result.usageData!.modelUsage['claude-haiku-4-5-20251001'].inputTokens).toBe(500);
|
|
320
|
+
});
|
|
238
321
|
});
|
|
239
322
|
|
|
240
323
|
describe('edge cases', () => {
|
|
@@ -4,8 +4,14 @@ import {
|
|
|
4
4
|
formatProjectHeader,
|
|
5
5
|
formatSummary,
|
|
6
6
|
formatProgressBar,
|
|
7
|
+
formatNumber,
|
|
8
|
+
formatCost,
|
|
9
|
+
formatTaskTokenSummary,
|
|
10
|
+
formatTokenTotalSummary,
|
|
7
11
|
TaskStatus,
|
|
8
12
|
} from '../../src/utils/terminal-symbols.js';
|
|
13
|
+
import type { UsageData } from '../../src/types/config.js';
|
|
14
|
+
import type { CostBreakdown } from '../../src/utils/token-tracker.js';
|
|
9
15
|
|
|
10
16
|
describe('Terminal Symbols', () => {
|
|
11
17
|
describe('SYMBOLS', () => {
|
|
@@ -231,4 +237,155 @@ describe('Terminal Symbols', () => {
|
|
|
231
237
|
expect(result).toBe('✓✗⊘○');
|
|
232
238
|
});
|
|
233
239
|
});
|
|
240
|
+
|
|
241
|
+
describe('formatNumber', () => {
|
|
242
|
+
it('should format small numbers without separators', () => {
|
|
243
|
+
expect(formatNumber(42)).toBe('42');
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
it('should format numbers with thousands separators', () => {
|
|
247
|
+
expect(formatNumber(12345)).toBe('12,345');
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
it('should format large numbers', () => {
|
|
251
|
+
expect(formatNumber(1234567)).toBe('1,234,567');
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
it('should format zero', () => {
|
|
255
|
+
expect(formatNumber(0)).toBe('0');
|
|
256
|
+
});
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
describe('formatCost', () => {
|
|
260
|
+
it('should format zero cost', () => {
|
|
261
|
+
expect(formatCost(0)).toBe('$0.00');
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
it('should format normal costs with 2 decimals', () => {
|
|
265
|
+
expect(formatCost(1.23)).toBe('$1.23');
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
it('should format small costs with 4 decimals', () => {
|
|
269
|
+
expect(formatCost(0.0042)).toBe('$0.0042');
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
it('should format costs just at the threshold', () => {
|
|
273
|
+
expect(formatCost(0.01)).toBe('$0.01');
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
it('should format costs below threshold', () => {
|
|
277
|
+
expect(formatCost(0.009)).toBe('$0.0090');
|
|
278
|
+
});
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
describe('formatTaskTokenSummary', () => {
|
|
282
|
+
const makeUsage = (overrides: Partial<UsageData> = {}): UsageData => ({
|
|
283
|
+
inputTokens: 5234,
|
|
284
|
+
outputTokens: 1023,
|
|
285
|
+
cacheReadInputTokens: 0,
|
|
286
|
+
cacheCreationInputTokens: 0,
|
|
287
|
+
modelUsage: {},
|
|
288
|
+
...overrides,
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
const makeCost = (total: number): CostBreakdown => ({
|
|
292
|
+
inputCost: 0,
|
|
293
|
+
outputCost: 0,
|
|
294
|
+
cacheReadCost: 0,
|
|
295
|
+
cacheCreateCost: 0,
|
|
296
|
+
totalCost: total,
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
it('should format basic token summary without cache', () => {
|
|
300
|
+
const result = formatTaskTokenSummary(makeUsage(), makeCost(0.42));
|
|
301
|
+
expect(result).toBe(' Tokens: 5,234 in / 1,023 out | Est. cost: $0.42');
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
it('should include cache read tokens', () => {
|
|
305
|
+
const result = formatTaskTokenSummary(
|
|
306
|
+
makeUsage({ cacheReadInputTokens: 18500 }),
|
|
307
|
+
makeCost(0.42)
|
|
308
|
+
);
|
|
309
|
+
expect(result).toBe(' Tokens: 5,234 in / 1,023 out | Cache: 18,500 read | Est. cost: $0.42');
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
it('should include cache creation tokens', () => {
|
|
313
|
+
const result = formatTaskTokenSummary(
|
|
314
|
+
makeUsage({ cacheCreationInputTokens: 5000 }),
|
|
315
|
+
makeCost(0.55)
|
|
316
|
+
);
|
|
317
|
+
expect(result).toBe(' Tokens: 5,234 in / 1,023 out | Cache: 5,000 created | Est. cost: $0.55');
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
it('should include both cache read and creation tokens', () => {
|
|
321
|
+
const result = formatTaskTokenSummary(
|
|
322
|
+
makeUsage({ cacheReadInputTokens: 18500, cacheCreationInputTokens: 5000 }),
|
|
323
|
+
makeCost(0.75)
|
|
324
|
+
);
|
|
325
|
+
expect(result).toBe(' Tokens: 5,234 in / 1,023 out | Cache: 18,500 read / 5,000 created | Est. cost: $0.75');
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
it('should format small costs with 4 decimal places', () => {
|
|
329
|
+
const result = formatTaskTokenSummary(makeUsage(), makeCost(0.0042));
|
|
330
|
+
expect(result).toBe(' Tokens: 5,234 in / 1,023 out | Est. cost: $0.0042');
|
|
331
|
+
});
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
describe('formatTokenTotalSummary', () => {
|
|
335
|
+
const makeUsage = (overrides: Partial<UsageData> = {}): UsageData => ({
|
|
336
|
+
inputTokens: 45678,
|
|
337
|
+
outputTokens: 12345,
|
|
338
|
+
cacheReadInputTokens: 0,
|
|
339
|
+
cacheCreationInputTokens: 0,
|
|
340
|
+
modelUsage: {},
|
|
341
|
+
...overrides,
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
const makeCost = (total: number): CostBreakdown => ({
|
|
345
|
+
inputCost: 0,
|
|
346
|
+
outputCost: 0,
|
|
347
|
+
cacheReadCost: 0,
|
|
348
|
+
cacheCreateCost: 0,
|
|
349
|
+
totalCost: total,
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
it('should format total summary without cache', () => {
|
|
353
|
+
const result = formatTokenTotalSummary(makeUsage(), makeCost(3.75));
|
|
354
|
+
expect(result).toContain('Token Usage Summary');
|
|
355
|
+
expect(result).toContain('Total tokens: 45,678 in / 12,345 out');
|
|
356
|
+
expect(result).toContain('Estimated cost: $3.75');
|
|
357
|
+
expect(result).not.toContain('Cache:');
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
it('should include cache read in total summary', () => {
|
|
361
|
+
const result = formatTokenTotalSummary(
|
|
362
|
+
makeUsage({ cacheReadInputTokens: 125000 }),
|
|
363
|
+
makeCost(3.75)
|
|
364
|
+
);
|
|
365
|
+
expect(result).toContain('Cache: 125,000 read');
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
it('should include cache creation in total summary', () => {
|
|
369
|
+
const result = formatTokenTotalSummary(
|
|
370
|
+
makeUsage({ cacheCreationInputTokens: 8000 }),
|
|
371
|
+
makeCost(3.75)
|
|
372
|
+
);
|
|
373
|
+
expect(result).toContain('Cache: 8,000 created');
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
it('should include both cache types in total summary', () => {
|
|
377
|
+
const result = formatTokenTotalSummary(
|
|
378
|
+
makeUsage({ cacheReadInputTokens: 125000, cacheCreationInputTokens: 8000 }),
|
|
379
|
+
makeCost(3.75)
|
|
380
|
+
);
|
|
381
|
+
expect(result).toContain('Cache: 125,000 read / 8,000 created');
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
it('should have divider lines', () => {
|
|
385
|
+
const result = formatTokenTotalSummary(makeUsage(), makeCost(3.75));
|
|
386
|
+
const lines = result.split('\n');
|
|
387
|
+
expect(lines[0]).toContain('──');
|
|
388
|
+
expect(lines[lines.length - 1]).toContain('──');
|
|
389
|
+
});
|
|
390
|
+
});
|
|
234
391
|
});
|