@rahul05ranjan/dhruv-cli 1.3.0 → 1.5.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/.github/ENTERPRISE.md +275 -0
- package/.github/ISSUE_TEMPLATE/bug_report.md +45 -17
- package/.github/ISSUE_TEMPLATE/documentation_issue.md +61 -0
- package/.github/ISSUE_TEMPLATE/feature_request.md +61 -9
- package/.github/ISSUE_TEMPLATE/security_vulnerability.md +74 -0
- package/.github/dependabot.yml +42 -2
- package/.github/pull_request_template.md +13 -0
- package/.github/workflows/ci.yml +51 -38
- package/.github/workflows/contribution.yml +41 -34
- package/.github/workflows/dependabot-auto-merge.yml +62 -2
- package/.github/workflows/labeler.yml +1 -0
- package/.github/workflows/release.yml +229 -0
- package/.github/workflows/security.yml +201 -0
- package/.releaserc.json +50 -0
- package/AGENTS.md +13 -0
- package/CHANGELOG.md +13 -0
- package/README.md +145 -40
- package/__tests__/cli-contract.test.ts +104 -0
- package/__tests__/core.test.ts +439 -0
- package/__tests__/diagnostics.test.ts +155 -0
- package/__tests__/file-workflows.test.ts +195 -0
- package/__tests__/interactive.test.ts +119 -0
- package/__tests__/setup.ts +62 -0
- package/__tests__/workflows.test.ts +118 -0
- package/dist/commands/explain.js +13 -44
- package/dist/commands/fix.js +13 -38
- package/dist/commands/generate.d.ts +6 -1
- package/dist/commands/generate.js +60 -55
- package/dist/commands/health.d.ts +4 -0
- package/dist/commands/health.js +419 -0
- package/dist/commands/init.js +56 -42
- package/dist/commands/menu.js +137 -24
- package/dist/commands/metrics.d.ts +5 -0
- package/dist/commands/metrics.js +80 -0
- package/dist/commands/optimize.js +42 -34
- package/dist/commands/review.d.ts +4 -1
- package/dist/commands/review.js +87 -43
- package/dist/commands/security-check.d.ts +4 -1
- package/dist/commands/security-check.js +109 -38
- package/dist/commands/status.d.ts +1 -0
- package/dist/commands/status.js +83 -0
- package/dist/commands/suggest.js +13 -39
- package/dist/config/config.d.ts +5 -1
- package/dist/config/config.js +53 -8
- package/dist/core/ai.d.ts +88 -2
- package/dist/core/ai.js +231 -30
- package/dist/core/command-catalog.d.ts +10 -0
- package/dist/core/command-catalog.js +27 -0
- package/dist/core/command-runner.d.ts +17 -0
- package/dist/core/command-runner.js +157 -0
- package/dist/core/logger.d.ts +40 -0
- package/dist/core/logger.js +139 -0
- package/dist/core/metrics.d.ts +62 -0
- package/dist/core/metrics.js +284 -0
- package/dist/core/prompts.d.ts +1 -0
- package/dist/core/prompts.js +121 -0
- package/dist/core/security.d.ts +34 -0
- package/dist/core/security.js +197 -0
- package/dist/index.js +84 -22
- package/dist/utils/projectType.d.ts +1 -1
- package/dist/utils/projectType.js +26 -7
- package/dist/utils/ux.d.ts +3 -0
- package/dist/utils/ux.js +15 -0
- package/docs/agents/domain.md +51 -0
- package/docs/agents/issue-tracker.md +45 -0
- package/docs/agents/triage-labels.md +15 -0
- package/docs/api/.nojekyll +1 -0
- package/docs/api/assets/hierarchy.js +1 -0
- package/docs/api/assets/highlight.css +71 -0
- package/docs/api/assets/icons.js +18 -0
- package/docs/api/assets/icons.svg +1 -0
- package/docs/api/assets/main.js +60 -0
- package/docs/api/assets/navigation.js +1 -0
- package/docs/api/assets/search.js +1 -0
- package/docs/api/assets/style.css +1633 -0
- package/docs/api/hierarchy.html +1 -0
- package/docs/api/index.html +161 -0
- package/docs/api/media/CONTRIBUTING.md +60 -0
- package/docs/api/media/SECURITY.md +8 -0
- package/docs/api/media/dhruv-cli-preview.svg +42 -0
- package/docs/api/media/publishing-fix.md +34 -0
- package/docs/api/modules.html +1 -0
- package/docs/dhruv-cli-preview.svg +42 -0
- package/docs/index.html +631 -533
- package/docs/publishing-fix.md +34 -0
- package/eslint.config.js +170 -0
- package/jest.config.json +37 -0
- package/lighthouserc.json +22 -0
- package/package.json +62 -8
- package/src/commands/explain.ts +13 -42
- package/src/commands/fix.ts +13 -31
- package/src/commands/generate.ts +68 -48
- package/src/commands/health.ts +485 -0
- package/src/commands/init.ts +57 -42
- package/src/commands/menu.ts +132 -24
- package/src/commands/metrics.ts +100 -0
- package/src/commands/optimize.ts +40 -28
- package/src/commands/review.ts +96 -37
- package/src/commands/security-check.ts +127 -33
- package/src/commands/status.ts +83 -0
- package/src/commands/suggest.ts +13 -32
- package/src/config/config.ts +60 -8
- package/src/core/ai.ts +265 -26
- package/src/core/command-catalog.ts +37 -0
- package/src/core/command-runner.ts +185 -0
- package/src/core/logger.ts +195 -0
- package/src/core/metrics.ts +335 -0
- package/src/core/prompts.ts +128 -0
- package/src/core/security.ts +243 -0
- package/src/index.ts +90 -22
- package/src/utils/projectType.ts +22 -7
- package/src/utils/ux.ts +18 -0
- package/test-suite.sh +147 -0
- package/tsconfig.json +4 -3
- package/typedoc.json +44 -0
- package/types/global.d.ts +13 -0
- package/validate-workflows.sh +270 -0
- package/.eslintignore +0 -1
- package/.eslintrc.cjs +0 -43
- package/.github/workflows/auto-assign.yml +0 -14
- package/src/core/ai.test.js +0 -40
|
@@ -0,0 +1,439 @@
|
|
|
1
|
+
import { describe, it, expect, jest, beforeEach, afterEach } from '@jest/globals';
|
|
2
|
+
import {
|
|
3
|
+
ask,
|
|
4
|
+
listModels,
|
|
5
|
+
setAIClient,
|
|
6
|
+
InMemoryAIClient,
|
|
7
|
+
} from '../src/core/ai';
|
|
8
|
+
import { loadConfig, saveConfig } from '../src/config/config';
|
|
9
|
+
import { createSpinner } from '../src/utils/ux';
|
|
10
|
+
import { detectProjectType } from '../src/utils/projectType';
|
|
11
|
+
import { getSystemMessage } from '../src/core/prompts';
|
|
12
|
+
import { runCommand } from '../src/core/command-runner';
|
|
13
|
+
import fs from 'fs';
|
|
14
|
+
import path from 'path';
|
|
15
|
+
|
|
16
|
+
// chalk and ora are ESM-only and can't be loaded by the CJS test runtime.
|
|
17
|
+
// Identity stubs stand in: the tests assert pipeline behavior, not coloring.
|
|
18
|
+
jest.mock('chalk', () => {
|
|
19
|
+
// Self-chaining identity: chalk.green.bold('x') === 'x', any chain depth.
|
|
20
|
+
const identity = (s: unknown) => String(s);
|
|
21
|
+
const makeChalk = (): unknown =>
|
|
22
|
+
new Proxy(identity, {
|
|
23
|
+
get: (_target: unknown, prop: string | symbol) => {
|
|
24
|
+
if (prop === 'level') return 0;
|
|
25
|
+
if (prop === Symbol.toPrimitive) return () => '';
|
|
26
|
+
return makeChalk();
|
|
27
|
+
},
|
|
28
|
+
apply: (_target: unknown, _thisArg: unknown, args: unknown[]) => String(args[0]),
|
|
29
|
+
});
|
|
30
|
+
const chalk = makeChalk() as unknown as Record<string, unknown>;
|
|
31
|
+
return { __esModule: true, default: chalk, ...chalk };
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
jest.mock('ora', () => ({
|
|
35
|
+
__esModule: true,
|
|
36
|
+
default: jest.fn(() => ({
|
|
37
|
+
start: jest.fn().mockReturnThis(),
|
|
38
|
+
stop: jest.fn().mockReturnThis(),
|
|
39
|
+
succeed: jest.fn().mockReturnThis(),
|
|
40
|
+
fail: jest.fn().mockReturnThis(),
|
|
41
|
+
})),
|
|
42
|
+
}));
|
|
43
|
+
|
|
44
|
+
// UX output is mocked at its own module edge — the runner's pipeline behavior
|
|
45
|
+
// is what's under test, not console formatting.
|
|
46
|
+
jest.mock('../src/utils/ux', () => ({
|
|
47
|
+
printError: jest.fn(),
|
|
48
|
+
printSuccess: jest.fn(),
|
|
49
|
+
printWarning: jest.fn(),
|
|
50
|
+
printInfo: jest.fn(),
|
|
51
|
+
createSpinner: jest.fn(() => ({
|
|
52
|
+
start: jest.fn().mockReturnThis(),
|
|
53
|
+
stop: jest.fn().mockReturnThis(),
|
|
54
|
+
succeed: jest.fn().mockReturnThis(),
|
|
55
|
+
fail: jest.fn().mockReturnThis(),
|
|
56
|
+
})),
|
|
57
|
+
themed: jest.fn((text: string) => text),
|
|
58
|
+
highlightCode: jest.fn((code: string) => code),
|
|
59
|
+
createProgressBar: jest.fn(() => ({
|
|
60
|
+
increment: jest.fn(),
|
|
61
|
+
stop: jest.fn(),
|
|
62
|
+
})),
|
|
63
|
+
}));
|
|
64
|
+
|
|
65
|
+
jest.mock('../src/core/logger', () => ({
|
|
66
|
+
logger: {
|
|
67
|
+
info: jest.fn(),
|
|
68
|
+
warn: jest.fn(),
|
|
69
|
+
error: jest.fn(),
|
|
70
|
+
debug: jest.fn(),
|
|
71
|
+
command: jest.fn(),
|
|
72
|
+
performance: jest.fn(),
|
|
73
|
+
security: jest.fn(),
|
|
74
|
+
getSessionId: jest.fn(() => 'test-session'),
|
|
75
|
+
flush: jest.fn(),
|
|
76
|
+
},
|
|
77
|
+
logCommand: jest.fn(),
|
|
78
|
+
logPerformance: jest.fn(),
|
|
79
|
+
logSecurity: jest.fn(),
|
|
80
|
+
logError: jest.fn(),
|
|
81
|
+
logInfo: jest.fn(),
|
|
82
|
+
logWarn: jest.fn(),
|
|
83
|
+
logDebug: jest.fn(),
|
|
84
|
+
}));
|
|
85
|
+
|
|
86
|
+
describe('Dhruv CLI Core Systems', () => {
|
|
87
|
+
const originalEnv = process.env;
|
|
88
|
+
|
|
89
|
+
beforeEach(() => {
|
|
90
|
+
process.env = { ...originalEnv };
|
|
91
|
+
jest.clearAllMocks();
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
afterEach(() => {
|
|
95
|
+
process.env = originalEnv;
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
describe('Configuration Management', () => {
|
|
99
|
+
it('should load default configuration', () => {
|
|
100
|
+
const config = loadConfig();
|
|
101
|
+
expect(config).toHaveProperty('model');
|
|
102
|
+
expect(config).toHaveProperty('verbose');
|
|
103
|
+
expect(config).toHaveProperty('responseFormat');
|
|
104
|
+
expect(config).toHaveProperty('theme');
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it('should validate and merge configuration', () => {
|
|
108
|
+
const testConfig = {
|
|
109
|
+
model: 'test-model',
|
|
110
|
+
verbose: true,
|
|
111
|
+
responseFormat: 'json' as const,
|
|
112
|
+
theme: 'dark' as const,
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
saveConfig(testConfig);
|
|
116
|
+
const loadedConfig = loadConfig();
|
|
117
|
+
|
|
118
|
+
expect(loadedConfig.model).toBe('test-model');
|
|
119
|
+
expect(loadedConfig.verbose).toBe(true);
|
|
120
|
+
expect(loadedConfig.responseFormat).toBe('json');
|
|
121
|
+
expect(loadedConfig.theme).toBe('dark');
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it('should handle invalid configuration gracefully', () => {
|
|
125
|
+
const configPath = path.join(process.cwd(), '.dhruv-config.json');
|
|
126
|
+
fs.writeFileSync(configPath, 'invalid json');
|
|
127
|
+
|
|
128
|
+
const config = loadConfig();
|
|
129
|
+
|
|
130
|
+
expect(config).toHaveProperty('model');
|
|
131
|
+
expect(config.verbose).toBe(false);
|
|
132
|
+
|
|
133
|
+
if (fs.existsSync(configPath)) {
|
|
134
|
+
fs.unlinkSync(configPath);
|
|
135
|
+
}
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
describe('Project Type Detection', () => {
|
|
140
|
+
it('should detect Node.js project', () => {
|
|
141
|
+
const mockExistsSync = jest.spyOn(fs, 'existsSync');
|
|
142
|
+
mockExistsSync.mockImplementation((filePath: fs.PathLike) => {
|
|
143
|
+
return path.basename(filePath.toString()) === 'package.json';
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
const mockReadFileSync = jest.spyOn(fs, 'readFileSync');
|
|
147
|
+
mockReadFileSync.mockReturnValue(JSON.stringify({
|
|
148
|
+
name: 'test-project',
|
|
149
|
+
dependencies: { express: '^4.0.0' }
|
|
150
|
+
}));
|
|
151
|
+
|
|
152
|
+
const projectType = detectProjectType();
|
|
153
|
+
expect(projectType).toBe('node-express');
|
|
154
|
+
|
|
155
|
+
mockExistsSync.mockRestore();
|
|
156
|
+
mockReadFileSync.mockRestore();
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it('should detect React project', () => {
|
|
160
|
+
const mockExistsSync = jest.spyOn(fs, 'existsSync');
|
|
161
|
+
mockExistsSync.mockImplementation((filePath: fs.PathLike) => {
|
|
162
|
+
const basename = path.basename(filePath.toString());
|
|
163
|
+
return basename === 'package.json' || basename === 'src';
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
const mockReadFileSync = jest.spyOn(fs, 'readFileSync');
|
|
167
|
+
mockReadFileSync.mockReturnValue(JSON.stringify({
|
|
168
|
+
name: 'test-react-app',
|
|
169
|
+
dependencies: { 'react': '^18.0.0', 'react-dom': '^18.0.0' }
|
|
170
|
+
}));
|
|
171
|
+
|
|
172
|
+
const projectType = detectProjectType();
|
|
173
|
+
expect(projectType).toBe('react');
|
|
174
|
+
|
|
175
|
+
mockExistsSync.mockRestore();
|
|
176
|
+
mockReadFileSync.mockRestore();
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it('should return unknown for unrecognized projects', () => {
|
|
180
|
+
const mockExistsSync = jest.spyOn(fs, 'existsSync');
|
|
181
|
+
mockExistsSync.mockReturnValue(false);
|
|
182
|
+
|
|
183
|
+
const projectType = detectProjectType();
|
|
184
|
+
expect(projectType).toBe('unknown');
|
|
185
|
+
|
|
186
|
+
mockExistsSync.mockRestore();
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it('should detect a TypeScript Node project', () => {
|
|
190
|
+
const mockExistsSync = jest.spyOn(fs, 'existsSync');
|
|
191
|
+
mockExistsSync.mockImplementation((filePath: fs.PathLike) => path.basename(filePath.toString()) === 'package.json');
|
|
192
|
+
const mockReadFileSync = jest.spyOn(fs, 'readFileSync');
|
|
193
|
+
mockReadFileSync.mockReturnValue(JSON.stringify({ devDependencies: { typescript: '^5.0.0' } }));
|
|
194
|
+
|
|
195
|
+
expect(detectProjectType()).toBe('node-typescript');
|
|
196
|
+
|
|
197
|
+
mockExistsSync.mockRestore();
|
|
198
|
+
mockReadFileSync.mockRestore();
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it('should return unknown instead of throwing for malformed package metadata', () => {
|
|
202
|
+
const mockExistsSync = jest.spyOn(fs, 'existsSync');
|
|
203
|
+
mockExistsSync.mockImplementation((filePath: fs.PathLike) => path.basename(filePath.toString()) === 'package.json');
|
|
204
|
+
const mockReadFileSync = jest.spyOn(fs, 'readFileSync');
|
|
205
|
+
mockReadFileSync.mockReturnValue('{ malformed');
|
|
206
|
+
|
|
207
|
+
expect(detectProjectType()).toBe('unknown');
|
|
208
|
+
|
|
209
|
+
mockExistsSync.mockRestore();
|
|
210
|
+
mockReadFileSync.mockRestore();
|
|
211
|
+
});
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
describe('System Message Templates', () => {
|
|
215
|
+
it('should return system message for valid type', () => {
|
|
216
|
+
const explainMessage = getSystemMessage('explain');
|
|
217
|
+
expect(explainMessage).toContain('programming instructor');
|
|
218
|
+
expect(explainMessage).toContain('technical expert');
|
|
219
|
+
|
|
220
|
+
const suggestMessage = getSystemMessage('suggest');
|
|
221
|
+
expect(suggestMessage).toContain('software architect');
|
|
222
|
+
expect(suggestMessage).toContain('best practices');
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
it('should return default message for invalid type', () => {
|
|
226
|
+
const defaultMessage = getSystemMessage('invalid');
|
|
227
|
+
const explainMessage = getSystemMessage('explain');
|
|
228
|
+
expect(defaultMessage).toBe(explainMessage);
|
|
229
|
+
});
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
describe('AI module through its interface (in-memory adapter)', () => {
|
|
233
|
+
let client: InMemoryAIClient;
|
|
234
|
+
|
|
235
|
+
beforeEach(() => {
|
|
236
|
+
client = new InMemoryAIClient(new Map([['hello', 'cached answer']]));
|
|
237
|
+
setAIClient(client);
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
it('streams tokens and returns the full response', async () => {
|
|
241
|
+
const tokens: string[] = [];
|
|
242
|
+
const response = await ask({ prompt: 'hello', onToken: (t) => tokens.push(t) });
|
|
243
|
+
expect(response).toBe('cached answer');
|
|
244
|
+
expect(tokens).toEqual(['cached answer']);
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
it('returns the same response for a repeated request without recomputing', async () => {
|
|
248
|
+
const first = await ask({ prompt: 'hello' });
|
|
249
|
+
const second = await ask({ prompt: 'hello' });
|
|
250
|
+
expect(first).toBe(second);
|
|
251
|
+
expect(client.computations).toBe(1);
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
it('recomputes when the cache entry expires', async () => {
|
|
255
|
+
await ask({ prompt: 'hello' });
|
|
256
|
+
const before = client.computations;
|
|
257
|
+
|
|
258
|
+
// Simulate the entry aging past the TTL.
|
|
259
|
+
const originalNow = client.now;
|
|
260
|
+
client.now = () => originalNow() + 25 * 60 * 60 * 1000;
|
|
261
|
+
|
|
262
|
+
await ask({ prompt: 'hello' });
|
|
263
|
+
expect(client.computations).toBe(before + 1);
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
it('surfaces model-not-found as a typed error', async () => {
|
|
267
|
+
client.failures.set('missing-model', { kind: 'model-not-found', model: 'nope' });
|
|
268
|
+
await expect(ask({ prompt: 'missing-model please' })).rejects.toMatchObject({
|
|
269
|
+
kind: 'model-not-found',
|
|
270
|
+
});
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
it('surfaces connection failure as a typed error', async () => {
|
|
274
|
+
client.failures.set('down', { kind: 'connection', cause: 'ECONNREFUSED' });
|
|
275
|
+
await expect(ask({ prompt: 'down service' })).rejects.toMatchObject({
|
|
276
|
+
kind: 'connection',
|
|
277
|
+
});
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
it('lists models through the interface', async () => {
|
|
281
|
+
const models = await listModels();
|
|
282
|
+
expect(models).toContain('test-model');
|
|
283
|
+
});
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
describe('Command runner through its seam (fake AI adapter injected)', () => {
|
|
287
|
+
let client: InMemoryAIClient;
|
|
288
|
+
|
|
289
|
+
beforeEach(() => {
|
|
290
|
+
client = new InMemoryAIClient(new Map([['happy', 'the answer']]));
|
|
291
|
+
setAIClient(client);
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
function makeSpec(overrides: Partial<Parameters<typeof runCommand>[0]> = {}) {
|
|
295
|
+
return {
|
|
296
|
+
name: 'explain',
|
|
297
|
+
input: { query: 'happy' },
|
|
298
|
+
header: '📚 Explanation: ',
|
|
299
|
+
buildRequest: (input: Record<string, string>, model: string) => ({
|
|
300
|
+
prompt: input.query,
|
|
301
|
+
systemMessage: 'sys',
|
|
302
|
+
model,
|
|
303
|
+
}),
|
|
304
|
+
...overrides,
|
|
305
|
+
};
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
it('runs the full pipeline for a happy path', async () => {
|
|
309
|
+
let completed: string | undefined;
|
|
310
|
+
await runCommand(makeSpec({ onComplete: (response) => { completed = response; } }));
|
|
311
|
+
expect(completed).toBe('the answer');
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
it('shows the AI response to the user', async () => {
|
|
315
|
+
const output: string[] = [];
|
|
316
|
+
const write = jest.spyOn(process.stdout, 'write').mockImplementation((chunk: unknown) => {
|
|
317
|
+
output.push(String(chunk));
|
|
318
|
+
return true;
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
await runCommand(makeSpec());
|
|
322
|
+
|
|
323
|
+
expect(output.join('')).toContain('the answer');
|
|
324
|
+
write.mockRestore();
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
it('emits one structured result in JSON mode', async () => {
|
|
328
|
+
saveConfig({ responseFormat: 'json' });
|
|
329
|
+
const output: string[] = [];
|
|
330
|
+
const write = jest.spyOn(process.stdout, 'write').mockImplementation((chunk: unknown) => {
|
|
331
|
+
output.push(String(chunk));
|
|
332
|
+
return true;
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
try {
|
|
336
|
+
await runCommand(makeSpec());
|
|
337
|
+
const result = JSON.parse(output.join('')) as Record<string, unknown>;
|
|
338
|
+
expect(result).toMatchObject({
|
|
339
|
+
ok: true,
|
|
340
|
+
command: 'explain',
|
|
341
|
+
response: 'the answer',
|
|
342
|
+
});
|
|
343
|
+
} finally {
|
|
344
|
+
write.mockRestore();
|
|
345
|
+
saveConfig({ responseFormat: 'text' });
|
|
346
|
+
}
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
it('short-circuits on validation failure before the AI call', async () => {
|
|
350
|
+
const computationsBefore = client.computations;
|
|
351
|
+
await runCommand(makeSpec({ input: { query: '<script>alert(1)</script>' } }));
|
|
352
|
+
expect(client.computations).toBe(computationsBefore);
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
it('maps a typed connection error to the ollama-serve hint', async () => {
|
|
356
|
+
client.failures.set('happy', { kind: 'connection', cause: 'ECONNREFUSED' });
|
|
357
|
+
await runCommand(makeSpec());
|
|
358
|
+
const { printError } = await import('../src/utils/ux');
|
|
359
|
+
expect(jest.mocked(printError).mock.calls.length).toBeGreaterThan(0);
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
it('maps a typed model-not-found error to the pull hint', async () => {
|
|
363
|
+
client.failures.set('happy', { kind: 'model-not-found', model: 'nope' });
|
|
364
|
+
await runCommand(makeSpec());
|
|
365
|
+
const { printError } = await import('../src/utils/ux');
|
|
366
|
+
expect(jest.mocked(printError).mock.calls.length).toBeGreaterThan(0);
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
it('marks the process unsuccessful when an AI command fails', async () => {
|
|
370
|
+
const originalExitCode = process.exitCode;
|
|
371
|
+
process.exitCode = undefined;
|
|
372
|
+
client.failures.set('happy', { kind: 'connection', cause: 'ECONNREFUSED' });
|
|
373
|
+
|
|
374
|
+
try {
|
|
375
|
+
await runCommand(makeSpec());
|
|
376
|
+
expect(process.exitCode).toBe(1);
|
|
377
|
+
} finally {
|
|
378
|
+
process.exitCode = originalExitCode;
|
|
379
|
+
}
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
it('treats an empty AI response as a failed command', async () => {
|
|
383
|
+
setAIClient(new InMemoryAIClient(new Map([['happy', '']])));
|
|
384
|
+
process.exitCode = undefined;
|
|
385
|
+
|
|
386
|
+
try {
|
|
387
|
+
await runCommand(makeSpec());
|
|
388
|
+
expect(process.exitCode).toBe(1);
|
|
389
|
+
} finally {
|
|
390
|
+
process.exitCode = undefined;
|
|
391
|
+
}
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
it('times out a long-running AI request with a failing exit code', async () => {
|
|
395
|
+
saveConfig({ timeoutMs: 5 });
|
|
396
|
+
setAIClient({
|
|
397
|
+
ask: () => new Promise<string>(() => {}),
|
|
398
|
+
listModels: async () => [],
|
|
399
|
+
});
|
|
400
|
+
process.exitCode = undefined;
|
|
401
|
+
|
|
402
|
+
try {
|
|
403
|
+
await runCommand(makeSpec());
|
|
404
|
+
expect(process.exitCode).toBe(1);
|
|
405
|
+
} finally {
|
|
406
|
+
saveConfig({ timeoutMs: 45000 });
|
|
407
|
+
process.exitCode = undefined;
|
|
408
|
+
}
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
it('cancels an in-flight request on Ctrl-C', async () => {
|
|
412
|
+
saveConfig({ timeoutMs: 1000 });
|
|
413
|
+
setAIClient({
|
|
414
|
+
ask: () => new Promise<string>(() => {}),
|
|
415
|
+
listModels: async () => [],
|
|
416
|
+
});
|
|
417
|
+
process.exitCode = undefined;
|
|
418
|
+
|
|
419
|
+
try {
|
|
420
|
+
const running = runCommand(makeSpec());
|
|
421
|
+
await new Promise((resolve) => setTimeout(resolve, 5));
|
|
422
|
+
process.emit('SIGINT');
|
|
423
|
+
await running;
|
|
424
|
+
expect(process.exitCode).toBe(130);
|
|
425
|
+
} finally {
|
|
426
|
+
saveConfig({ timeoutMs: 45000 });
|
|
427
|
+
process.exitCode = undefined;
|
|
428
|
+
}
|
|
429
|
+
});
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
describe('UX Utilities', () => {
|
|
433
|
+
it('should create spinner correctly', () => {
|
|
434
|
+
const spinner = createSpinner('Testing...');
|
|
435
|
+
expect(spinner).toHaveProperty('start');
|
|
436
|
+
expect(spinner).toHaveProperty('stop');
|
|
437
|
+
});
|
|
438
|
+
});
|
|
439
|
+
});
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { describe, expect, it, jest } from '@jest/globals';
|
|
2
|
+
import { saveConfig } from '../src/config/config';
|
|
3
|
+
import { status } from '../src/commands/status';
|
|
4
|
+
import { health } from '../src/commands/health';
|
|
5
|
+
import { metrics } from '../src/commands/metrics';
|
|
6
|
+
import { getOllamaStatus, listModels } from '../src/core/ai';
|
|
7
|
+
import { metricsCollector } from '../src/core/metrics';
|
|
8
|
+
|
|
9
|
+
jest.mock('chalk', () => {
|
|
10
|
+
const identity = (value: unknown) => String(value);
|
|
11
|
+
const makeChalk = (): unknown => new Proxy(identity, {
|
|
12
|
+
get: (_target, property: string | symbol) => property === 'level' ? 0 : makeChalk(),
|
|
13
|
+
apply: (_target, _thisArg, args: unknown[]) => String(args[0]),
|
|
14
|
+
});
|
|
15
|
+
const chalk = makeChalk() as Record<string, unknown>;
|
|
16
|
+
return { __esModule: true, default: chalk, ...chalk };
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
jest.mock('../src/utils/ux', () => ({
|
|
20
|
+
printError: jest.fn(),
|
|
21
|
+
printSuccess: jest.fn(),
|
|
22
|
+
printWarning: jest.fn(),
|
|
23
|
+
printInfo: jest.fn(),
|
|
24
|
+
createSpinner: jest.fn(),
|
|
25
|
+
themed: jest.fn((value: string) => value),
|
|
26
|
+
highlightCode: jest.fn((value: string) => value),
|
|
27
|
+
createProgressBar: jest.fn(),
|
|
28
|
+
}));
|
|
29
|
+
|
|
30
|
+
jest.mock('../src/core/ai', () => ({
|
|
31
|
+
listModels: jest.fn(),
|
|
32
|
+
getOllamaStatus: jest.fn(),
|
|
33
|
+
}));
|
|
34
|
+
|
|
35
|
+
jest.mock('../src/core/logger', () => ({
|
|
36
|
+
logger: { info: jest.fn(), warn: jest.fn(), error: jest.fn() },
|
|
37
|
+
logInfo: jest.fn(),
|
|
38
|
+
logError: jest.fn(),
|
|
39
|
+
}));
|
|
40
|
+
|
|
41
|
+
describe('diagnostic commands', () => {
|
|
42
|
+
it('emits structured status output in JSON mode', async () => {
|
|
43
|
+
jest.mocked(listModels).mockResolvedValue(['test-model']);
|
|
44
|
+
jest.mocked(getOllamaStatus).mockResolvedValue({ endpoint: 'http://127.0.0.1:11434', version: '0.12.3' });
|
|
45
|
+
saveConfig({ model: 'test-model', responseFormat: 'json' });
|
|
46
|
+
const output: string[] = [];
|
|
47
|
+
const write = jest.spyOn(process.stdout, 'write').mockImplementation((chunk: unknown) => {
|
|
48
|
+
output.push(String(chunk));
|
|
49
|
+
return true;
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
try {
|
|
53
|
+
await status();
|
|
54
|
+
expect(JSON.parse(output.join(''))).toMatchObject({
|
|
55
|
+
ok: true,
|
|
56
|
+
command: 'status',
|
|
57
|
+
model: 'test-model',
|
|
58
|
+
availableModels: ['test-model'],
|
|
59
|
+
endpoint: 'http://127.0.0.1:11434',
|
|
60
|
+
version: '0.12.3',
|
|
61
|
+
});
|
|
62
|
+
} finally {
|
|
63
|
+
write.mockRestore();
|
|
64
|
+
saveConfig({ responseFormat: 'text' });
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('emits structured health output in JSON mode', async () => {
|
|
69
|
+
jest.mocked(listModels).mockResolvedValue(['test-model']);
|
|
70
|
+
saveConfig({ model: 'test-model', responseFormat: 'json' });
|
|
71
|
+
const output: string[] = [];
|
|
72
|
+
const write = jest.spyOn(process.stdout, 'write').mockImplementation((chunk: unknown) => {
|
|
73
|
+
output.push(String(chunk));
|
|
74
|
+
return true;
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
const memory = jest.spyOn(process, 'memoryUsage').mockReturnValue({
|
|
78
|
+
rss: 40 * 1024 * 1024,
|
|
79
|
+
heapTotal: 30 * 1024 * 1024,
|
|
80
|
+
heapUsed: 20 * 1024 * 1024,
|
|
81
|
+
external: 1024 * 1024,
|
|
82
|
+
arrayBuffers: 0,
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
try {
|
|
86
|
+
await health();
|
|
87
|
+
const result = JSON.parse(output.join('')) as Record<string, unknown>;
|
|
88
|
+
expect(result).toMatchObject({ ok: true, command: 'health' });
|
|
89
|
+
expect(result).toHaveProperty('results');
|
|
90
|
+
expect(result).toHaveProperty('summary');
|
|
91
|
+
} finally {
|
|
92
|
+
memory.mockRestore();
|
|
93
|
+
write.mockRestore();
|
|
94
|
+
saveConfig({ responseFormat: 'text' });
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('retains a local command summary across collector reads', () => {
|
|
99
|
+
metricsCollector.resetPersistent();
|
|
100
|
+
metricsCollector.recordCommand('explain', 1250, true);
|
|
101
|
+
|
|
102
|
+
expect(metricsCollector.getSummary()).toMatchObject({
|
|
103
|
+
commands: {
|
|
104
|
+
explain: {
|
|
105
|
+
runs: 1,
|
|
106
|
+
successes: 1,
|
|
107
|
+
failures: 0,
|
|
108
|
+
durationMs: 1250,
|
|
109
|
+
},
|
|
110
|
+
},
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
metricsCollector.resetPersistent();
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it('retains model and cache activity in the local summary', () => {
|
|
117
|
+
metricsCollector.resetPersistent();
|
|
118
|
+
metricsCollector.recordAIRequest('test-model', 'explain', 250, true);
|
|
119
|
+
metricsCollector.recordCacheHit('ai-response');
|
|
120
|
+
metricsCollector.recordCacheMiss('ai-response');
|
|
121
|
+
|
|
122
|
+
expect(metricsCollector.getSummary()).toMatchObject({
|
|
123
|
+
models: {
|
|
124
|
+
'test-model': { requests: 1, successes: 1, failures: 0, durationMs: 250 },
|
|
125
|
+
},
|
|
126
|
+
cache: { hits: 1, misses: 1 },
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
metricsCollector.resetPersistent();
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it('emits persisted metrics in JSON mode', async () => {
|
|
133
|
+
metricsCollector.resetPersistent();
|
|
134
|
+
metricsCollector.recordCommand('status', 50, true);
|
|
135
|
+
saveConfig({ responseFormat: 'json' });
|
|
136
|
+
const output: string[] = [];
|
|
137
|
+
const write = jest.spyOn(process.stdout, 'write').mockImplementation((chunk: unknown) => {
|
|
138
|
+
output.push(String(chunk));
|
|
139
|
+
return true;
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
try {
|
|
143
|
+
await metrics();
|
|
144
|
+
expect(JSON.parse(output.join(''))).toMatchObject({
|
|
145
|
+
ok: true,
|
|
146
|
+
command: 'metrics',
|
|
147
|
+
summary: { commands: { status: { runs: 1 } } },
|
|
148
|
+
});
|
|
149
|
+
} finally {
|
|
150
|
+
write.mockRestore();
|
|
151
|
+
saveConfig({ responseFormat: 'text' });
|
|
152
|
+
metricsCollector.resetPersistent();
|
|
153
|
+
}
|
|
154
|
+
});
|
|
155
|
+
});
|