cadr-cli 2.0.1 → 2.0.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/dist/analysis/analysis.orchestrator.test.d.ts +2 -0
- package/dist/analysis/analysis.orchestrator.test.d.ts.map +1 -0
- package/dist/analysis/analysis.orchestrator.test.js +177 -0
- package/dist/analysis/analysis.orchestrator.test.js.map +1 -0
- package/dist/analysis/strategies/git-strategy.test.d.ts +2 -0
- package/dist/analysis/strategies/git-strategy.test.d.ts.map +1 -0
- package/dist/analysis/strategies/git-strategy.test.js +147 -0
- package/dist/analysis/strategies/git-strategy.test.js.map +1 -0
- package/dist/commands/analyze.test.d.ts +2 -0
- package/dist/commands/analyze.test.d.ts.map +1 -0
- package/dist/commands/analyze.test.js +70 -0
- package/dist/commands/analyze.test.js.map +1 -0
- package/dist/commands/init.test.js +128 -2
- package/dist/commands/init.test.js.map +1 -1
- package/dist/config.test.js +167 -0
- package/dist/config.test.js.map +1 -1
- package/dist/git/git.errors.test.d.ts +2 -0
- package/dist/git/git.errors.test.d.ts.map +1 -0
- package/dist/git/git.errors.test.js +34 -0
- package/dist/git/git.errors.test.js.map +1 -0
- package/dist/git/git.operations.test.d.ts +2 -0
- package/dist/git/git.operations.test.d.ts.map +1 -0
- package/dist/git/git.operations.test.js +164 -0
- package/dist/git/git.operations.test.js.map +1 -0
- package/dist/llm/llm.test.d.ts +2 -0
- package/dist/llm/llm.test.d.ts.map +1 -0
- package/dist/llm/llm.test.js +224 -0
- package/dist/llm/llm.test.js.map +1 -0
- package/dist/llm/response-parser.test.d.ts +2 -0
- package/dist/llm/response-parser.test.d.ts.map +1 -0
- package/dist/llm/response-parser.test.js +134 -0
- package/dist/llm/response-parser.test.js.map +1 -0
- package/dist/presenters/console-presenter.test.d.ts +2 -0
- package/dist/presenters/console-presenter.test.d.ts.map +1 -0
- package/dist/presenters/console-presenter.test.js +227 -0
- package/dist/presenters/console-presenter.test.js.map +1 -0
- package/dist/version.test.d.ts +1 -2
- package/dist/version.test.d.ts.map +1 -1
- package/dist/version.test.js +29 -16
- package/dist/version.test.js.map +1 -1
- package/package.json +1 -1
- package/src/analysis/analysis.orchestrator.test.ts +237 -0
- package/src/analysis/strategies/git-strategy.test.ts +210 -0
- package/src/commands/analyze.test.ts +91 -0
- package/src/commands/init.test.ts +200 -5
- package/src/config.test.ts +232 -2
- package/src/git/git.errors.test.ts +43 -0
- package/src/git/git.operations.test.ts +222 -0
- package/src/llm/llm.test.ts +315 -0
- package/src/llm/response-parser.test.ts +170 -0
- package/src/presenters/console-presenter.test.ts +259 -0
- package/src/version.test.ts +30 -16
|
@@ -1,27 +1,222 @@
|
|
|
1
1
|
import { initCommand } from './init';
|
|
2
2
|
import * as config from '../config';
|
|
3
|
+
import { existsSync } from 'fs';
|
|
4
|
+
import { loggerInstance as logger } from '../logger';
|
|
3
5
|
|
|
4
6
|
// Mock dependencies
|
|
5
7
|
jest.mock('../config');
|
|
8
|
+
jest.mock('fs', () => ({
|
|
9
|
+
existsSync: jest.fn().mockReturnValue(false),
|
|
10
|
+
}));
|
|
11
|
+
jest.mock('../logger');
|
|
12
|
+
|
|
13
|
+
const mockExistsSync = existsSync as jest.Mock;
|
|
14
|
+
const mockCreateConfig = config.createConfig as jest.Mock;
|
|
15
|
+
const mockGetDefaultConfigPath = config.getDefaultConfigPath as jest.Mock;
|
|
16
|
+
const mockValidateConfig = config.validateConfig as jest.Mock;
|
|
17
|
+
|
|
18
|
+
function makeValidConfig(overrides: Partial<config.AnalysisConfig> = {}): config.AnalysisConfig {
|
|
19
|
+
return {
|
|
20
|
+
provider: 'openai',
|
|
21
|
+
analysis_model: 'gpt-4',
|
|
22
|
+
api_key_env: 'OPENAI_API_KEY',
|
|
23
|
+
timeout_seconds: 30,
|
|
24
|
+
...overrides,
|
|
25
|
+
};
|
|
26
|
+
}
|
|
6
27
|
|
|
7
28
|
describe('Init Command', () => {
|
|
29
|
+
let consoleSpy: {
|
|
30
|
+
log: jest.SpyInstance;
|
|
31
|
+
error: jest.SpyInstance;
|
|
32
|
+
warn: jest.SpyInstance;
|
|
33
|
+
};
|
|
34
|
+
|
|
8
35
|
beforeEach(() => {
|
|
9
36
|
jest.clearAllMocks();
|
|
37
|
+
mockExistsSync.mockReturnValue(false);
|
|
38
|
+
mockGetDefaultConfigPath.mockReturnValue('/fake/path/cadr.yaml');
|
|
39
|
+
mockValidateConfig.mockReturnValue({ valid: true, errors: [] });
|
|
40
|
+
consoleSpy = {
|
|
41
|
+
log: jest.spyOn(console, 'log').mockImplementation(),
|
|
42
|
+
error: jest.spyOn(console, 'error').mockImplementation(),
|
|
43
|
+
warn: jest.spyOn(console, 'warn').mockImplementation(),
|
|
44
|
+
};
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
afterEach(() => {
|
|
48
|
+
consoleSpy.log.mockRestore();
|
|
49
|
+
consoleSpy.error.mockRestore();
|
|
50
|
+
consoleSpy.warn.mockRestore();
|
|
10
51
|
});
|
|
11
52
|
|
|
12
53
|
describe('initCommand', () => {
|
|
13
54
|
test('calls createConfig when no config exists', async () => {
|
|
14
|
-
|
|
15
|
-
|
|
55
|
+
mockCreateConfig.mockResolvedValue(makeValidConfig());
|
|
56
|
+
|
|
16
57
|
await initCommand();
|
|
17
|
-
|
|
58
|
+
|
|
18
59
|
expect(config.createConfig).toHaveBeenCalled();
|
|
19
60
|
});
|
|
20
61
|
|
|
21
62
|
test('handles config creation errors gracefully', async () => {
|
|
22
|
-
|
|
23
|
-
|
|
63
|
+
mockCreateConfig.mockRejectedValue(new Error('Permission denied'));
|
|
64
|
+
|
|
24
65
|
await expect(initCommand()).resolves.not.toThrow();
|
|
25
66
|
});
|
|
67
|
+
|
|
68
|
+
test('prints already-exists message and does not call createConfig when config exists', async () => {
|
|
69
|
+
mockExistsSync.mockReturnValue(true);
|
|
70
|
+
|
|
71
|
+
await initCommand();
|
|
72
|
+
|
|
73
|
+
expect(consoleSpy.log).toHaveBeenCalledWith(
|
|
74
|
+
expect.stringContaining('already exists'),
|
|
75
|
+
);
|
|
76
|
+
expect(mockCreateConfig).not.toHaveBeenCalled();
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test('prints failure message and does not display summary when createConfig returns null', async () => {
|
|
80
|
+
mockCreateConfig.mockResolvedValue(null);
|
|
81
|
+
|
|
82
|
+
await initCommand();
|
|
83
|
+
|
|
84
|
+
expect(consoleSpy.error).toHaveBeenCalledWith(
|
|
85
|
+
expect.stringContaining('Failed to create configuration'),
|
|
86
|
+
);
|
|
87
|
+
// Summary header should NOT appear
|
|
88
|
+
const logCalls = consoleSpy.log.mock.calls.map((c: unknown[]) => c[0]);
|
|
89
|
+
expect(logCalls).not.toEqual(
|
|
90
|
+
expect.arrayContaining([expect.stringContaining('Configuration Summary')]),
|
|
91
|
+
);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test('displays summary with provider, model, api_key_env, and timeout when config is valid', async () => {
|
|
95
|
+
const cfg = makeValidConfig({
|
|
96
|
+
provider: 'gemini',
|
|
97
|
+
analysis_model: 'gemini-pro',
|
|
98
|
+
api_key_env: 'GEMINI_API_KEY',
|
|
99
|
+
timeout_seconds: 45,
|
|
100
|
+
});
|
|
101
|
+
mockCreateConfig.mockResolvedValue(cfg);
|
|
102
|
+
process.env['GEMINI_API_KEY'] = 'fake-key';
|
|
103
|
+
|
|
104
|
+
await initCommand();
|
|
105
|
+
|
|
106
|
+
expect(consoleSpy.log).toHaveBeenCalledWith('📋 Configuration Summary:');
|
|
107
|
+
expect(consoleSpy.log).toHaveBeenCalledWith(' Provider: gemini');
|
|
108
|
+
expect(consoleSpy.log).toHaveBeenCalledWith(' Model: gemini-pro');
|
|
109
|
+
expect(consoleSpy.log).toHaveBeenCalledWith(' API Key Env: GEMINI_API_KEY');
|
|
110
|
+
expect(consoleSpy.log).toHaveBeenCalledWith(' Timeout: 45s');
|
|
111
|
+
|
|
112
|
+
delete process.env['GEMINI_API_KEY'];
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test('includes ignore patterns line when config has ignore_patterns', async () => {
|
|
116
|
+
const cfg = makeValidConfig({
|
|
117
|
+
ignore_patterns: ['node_modules', '*.log'],
|
|
118
|
+
});
|
|
119
|
+
mockCreateConfig.mockResolvedValue(cfg);
|
|
120
|
+
process.env['OPENAI_API_KEY'] = 'fake-key';
|
|
121
|
+
|
|
122
|
+
await initCommand();
|
|
123
|
+
|
|
124
|
+
expect(consoleSpy.log).toHaveBeenCalledWith(
|
|
125
|
+
' Ignore Patterns: node_modules, *.log',
|
|
126
|
+
);
|
|
127
|
+
|
|
128
|
+
delete process.env['OPENAI_API_KEY'];
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test('does not show ignore patterns line when ignore_patterns is undefined', async () => {
|
|
132
|
+
const cfg = makeValidConfig();
|
|
133
|
+
delete cfg.ignore_patterns;
|
|
134
|
+
mockCreateConfig.mockResolvedValue(cfg);
|
|
135
|
+
process.env['OPENAI_API_KEY'] = 'fake-key';
|
|
136
|
+
|
|
137
|
+
await initCommand();
|
|
138
|
+
|
|
139
|
+
const logCalls = consoleSpy.log.mock.calls.map((c: unknown[]) => c[0]);
|
|
140
|
+
expect(logCalls).not.toEqual(
|
|
141
|
+
expect.arrayContaining([expect.stringContaining('Ignore Patterns')]),
|
|
142
|
+
);
|
|
143
|
+
|
|
144
|
+
delete process.env['OPENAI_API_KEY'];
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test('does not display warning when API key env var is set', async () => {
|
|
148
|
+
const cfg = makeValidConfig({ api_key_env: 'OPENAI_API_KEY' });
|
|
149
|
+
mockCreateConfig.mockResolvedValue(cfg);
|
|
150
|
+
process.env['OPENAI_API_KEY'] = 'fake-key';
|
|
151
|
+
|
|
152
|
+
await initCommand();
|
|
153
|
+
|
|
154
|
+
expect(consoleSpy.warn).not.toHaveBeenCalled();
|
|
155
|
+
|
|
156
|
+
delete process.env['OPENAI_API_KEY'];
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
test('displays warning with OpenAI link when API key env var is not set for OpenAI', async () => {
|
|
160
|
+
const cfg = makeValidConfig({ provider: 'openai', api_key_env: 'OPENAI_API_KEY' });
|
|
161
|
+
mockCreateConfig.mockResolvedValue(cfg);
|
|
162
|
+
delete process.env['OPENAI_API_KEY'];
|
|
163
|
+
|
|
164
|
+
await initCommand();
|
|
165
|
+
|
|
166
|
+
expect(consoleSpy.warn).toHaveBeenCalledWith(
|
|
167
|
+
expect.stringContaining('OPENAI_API_KEY is not set'),
|
|
168
|
+
);
|
|
169
|
+
expect(consoleSpy.warn).toHaveBeenCalledWith(
|
|
170
|
+
expect.stringContaining('https://platform.openai.com/api-keys'),
|
|
171
|
+
);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
test('displays warning with Gemini link when API key env var is not set for Gemini', async () => {
|
|
175
|
+
const cfg = makeValidConfig({ provider: 'gemini', api_key_env: 'GEMINI_API_KEY' });
|
|
176
|
+
mockCreateConfig.mockResolvedValue(cfg);
|
|
177
|
+
delete process.env['GEMINI_API_KEY'];
|
|
178
|
+
|
|
179
|
+
await initCommand();
|
|
180
|
+
|
|
181
|
+
expect(consoleSpy.warn).toHaveBeenCalledWith(
|
|
182
|
+
expect.stringContaining('GEMINI_API_KEY is not set'),
|
|
183
|
+
);
|
|
184
|
+
expect(consoleSpy.warn).toHaveBeenCalledWith(
|
|
185
|
+
expect.stringContaining('https://aistudio.google.com/app/apikey'),
|
|
186
|
+
);
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
test('logs validation warning when validateConfig returns invalid', async () => {
|
|
190
|
+
const cfg = makeValidConfig();
|
|
191
|
+
mockCreateConfig.mockResolvedValue(cfg);
|
|
192
|
+
mockValidateConfig.mockReturnValue({
|
|
193
|
+
valid: false,
|
|
194
|
+
errors: ['timeout too low'],
|
|
195
|
+
});
|
|
196
|
+
process.env['OPENAI_API_KEY'] = 'fake-key';
|
|
197
|
+
|
|
198
|
+
await initCommand();
|
|
199
|
+
|
|
200
|
+
expect(logger.warn).toHaveBeenCalledWith(
|
|
201
|
+
'Created config has validation warnings',
|
|
202
|
+
{ errors: ['timeout too low'] },
|
|
203
|
+
);
|
|
204
|
+
|
|
205
|
+
delete process.env['OPENAI_API_KEY'];
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
test('catches unexpected error and prints error message', async () => {
|
|
209
|
+
mockCreateConfig.mockRejectedValue(new Error('unexpected boom'));
|
|
210
|
+
|
|
211
|
+
await initCommand();
|
|
212
|
+
|
|
213
|
+
expect(consoleSpy.error).toHaveBeenCalledWith(
|
|
214
|
+
expect.stringContaining('unexpected error occurred'),
|
|
215
|
+
);
|
|
216
|
+
expect(logger.error).toHaveBeenCalledWith(
|
|
217
|
+
'Init command failed',
|
|
218
|
+
expect.objectContaining({ error: expect.any(Error) }),
|
|
219
|
+
);
|
|
220
|
+
});
|
|
26
221
|
});
|
|
27
222
|
});
|
package/src/config.test.ts
CHANGED
|
@@ -1,10 +1,21 @@
|
|
|
1
|
-
import { loadConfig, validateConfig, AnalysisConfig } from './config';
|
|
1
|
+
import { loadConfig, validateConfig, getDefaultConfigPath, createConfig, AnalysisConfig } from './config';
|
|
2
2
|
import * as fs from 'fs';
|
|
3
3
|
import * as yaml from 'js-yaml';
|
|
4
|
-
|
|
4
|
+
import * as readline from 'readline';
|
|
5
5
|
// Mock fs and yaml modules
|
|
6
6
|
jest.mock('fs');
|
|
7
7
|
jest.mock('js-yaml');
|
|
8
|
+
jest.mock('readline');
|
|
9
|
+
jest.mock('./logger', () => ({
|
|
10
|
+
loggerInstance: {
|
|
11
|
+
info: jest.fn(),
|
|
12
|
+
warn: jest.fn(),
|
|
13
|
+
error: jest.fn(),
|
|
14
|
+
},
|
|
15
|
+
}));
|
|
16
|
+
|
|
17
|
+
import { loggerInstance as logger } from './logger';
|
|
18
|
+
const mockLogger = logger as jest.Mocked<typeof logger>;
|
|
8
19
|
|
|
9
20
|
describe('Configuration Module', () => {
|
|
10
21
|
const mockConfig: AnalysisConfig = {
|
|
@@ -76,4 +87,223 @@ describe('Configuration Module', () => {
|
|
|
76
87
|
expect(result.errors.length).toBeGreaterThan(0);
|
|
77
88
|
});
|
|
78
89
|
});
|
|
90
|
+
|
|
91
|
+
// --- NEW TEST CASES ---
|
|
92
|
+
|
|
93
|
+
describe('loadConfig — additional branches', () => {
|
|
94
|
+
test('returns null when YAML parses to null', async () => {
|
|
95
|
+
(fs.existsSync as jest.Mock).mockReturnValue(true);
|
|
96
|
+
(fs.readFileSync as jest.Mock).mockReturnValue('');
|
|
97
|
+
(yaml.load as jest.Mock).mockReturnValue(null);
|
|
98
|
+
|
|
99
|
+
const result = await loadConfig('/test/cadr.yaml');
|
|
100
|
+
|
|
101
|
+
expect(result).toBeNull();
|
|
102
|
+
expect(mockLogger.error).toHaveBeenCalledWith(
|
|
103
|
+
'Invalid YAML configuration',
|
|
104
|
+
expect.objectContaining({ configPath: '/test/cadr.yaml' })
|
|
105
|
+
);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test('returns null when YAML parses to a string', async () => {
|
|
109
|
+
(fs.existsSync as jest.Mock).mockReturnValue(true);
|
|
110
|
+
(fs.readFileSync as jest.Mock).mockReturnValue('just a string');
|
|
111
|
+
(yaml.load as jest.Mock).mockReturnValue('just a string');
|
|
112
|
+
|
|
113
|
+
const result = await loadConfig('/test/cadr.yaml');
|
|
114
|
+
|
|
115
|
+
expect(result).toBeNull();
|
|
116
|
+
expect(mockLogger.error).toHaveBeenCalledWith(
|
|
117
|
+
'Invalid YAML configuration',
|
|
118
|
+
expect.objectContaining({ configPath: '/test/cadr.yaml' })
|
|
119
|
+
);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test('does not log warning when api_key_env is set in environment', async () => {
|
|
123
|
+
const envKey = 'TEST_API_KEY_PRESENT';
|
|
124
|
+
const configWithEnv = { ...mockConfig, api_key_env: envKey };
|
|
125
|
+
process.env[envKey] = 'some-key-value';
|
|
126
|
+
|
|
127
|
+
(fs.existsSync as jest.Mock).mockReturnValue(true);
|
|
128
|
+
(fs.readFileSync as jest.Mock).mockReturnValue('yaml');
|
|
129
|
+
(yaml.load as jest.Mock).mockReturnValue(configWithEnv);
|
|
130
|
+
|
|
131
|
+
const result = await loadConfig('/test/cadr.yaml');
|
|
132
|
+
|
|
133
|
+
expect(result).toEqual(configWithEnv);
|
|
134
|
+
expect(mockLogger.warn).not.toHaveBeenCalledWith(
|
|
135
|
+
'API key environment variable is not set',
|
|
136
|
+
expect.anything()
|
|
137
|
+
);
|
|
138
|
+
|
|
139
|
+
delete process.env[envKey];
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test('logs warning but still returns config when api_key_env is NOT set', async () => {
|
|
143
|
+
const envKey = 'MISSING_API_KEY_XYZ';
|
|
144
|
+
const configWithMissingEnv = { ...mockConfig, api_key_env: envKey };
|
|
145
|
+
delete process.env[envKey];
|
|
146
|
+
|
|
147
|
+
(fs.existsSync as jest.Mock).mockReturnValue(true);
|
|
148
|
+
(fs.readFileSync as jest.Mock).mockReturnValue('yaml');
|
|
149
|
+
(yaml.load as jest.Mock).mockReturnValue(configWithMissingEnv);
|
|
150
|
+
|
|
151
|
+
const result = await loadConfig('/test/cadr.yaml');
|
|
152
|
+
|
|
153
|
+
expect(result).toEqual(configWithMissingEnv);
|
|
154
|
+
expect(mockLogger.warn).toHaveBeenCalledWith(
|
|
155
|
+
'API key environment variable is not set',
|
|
156
|
+
{ api_key_env: envKey }
|
|
157
|
+
);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
test('returns null when readFileSync throws a permissions error', async () => {
|
|
161
|
+
(fs.existsSync as jest.Mock).mockReturnValue(true);
|
|
162
|
+
(fs.readFileSync as jest.Mock).mockImplementation(() => {
|
|
163
|
+
throw new Error('EACCES: permission denied');
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
const result = await loadConfig('/test/cadr.yaml');
|
|
167
|
+
|
|
168
|
+
expect(result).toBeNull();
|
|
169
|
+
expect(mockLogger.error).toHaveBeenCalledWith(
|
|
170
|
+
'Failed to load configuration',
|
|
171
|
+
expect.objectContaining({ configPath: '/test/cadr.yaml' })
|
|
172
|
+
);
|
|
173
|
+
});
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
describe('validateConfig — additional cases', () => {
|
|
177
|
+
test('accepts provider gemini as valid', () => {
|
|
178
|
+
const geminiConfig = { ...mockConfig, provider: 'gemini' };
|
|
179
|
+
const result = validateConfig(geminiConfig);
|
|
180
|
+
|
|
181
|
+
expect(result.valid).toBe(true);
|
|
182
|
+
expect(result.errors).toHaveLength(0);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
test('accepts timeout_seconds: 1 (min boundary) as valid', () => {
|
|
186
|
+
const config = { ...mockConfig, timeout_seconds: 1 };
|
|
187
|
+
const result = validateConfig(config);
|
|
188
|
+
|
|
189
|
+
expect(result.valid).toBe(true);
|
|
190
|
+
expect(result.errors).toHaveLength(0);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
test('accepts timeout_seconds: 60 (max boundary) as valid', () => {
|
|
194
|
+
const config = { ...mockConfig, timeout_seconds: 60 };
|
|
195
|
+
const result = validateConfig(config);
|
|
196
|
+
|
|
197
|
+
expect(result.valid).toBe(true);
|
|
198
|
+
expect(result.errors).toHaveLength(0);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
test('rejects timeout_seconds: 0 (below min) with error message', () => {
|
|
202
|
+
const config = { ...mockConfig, timeout_seconds: 0 };
|
|
203
|
+
const result = validateConfig(config);
|
|
204
|
+
|
|
205
|
+
expect(result.valid).toBe(false);
|
|
206
|
+
expect(result.errors.join('\n')).toMatch(/timeout_seconds must be at least 1 second/);
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
test('rejects timeout_seconds: 61 (above max)', () => {
|
|
210
|
+
const config = { ...mockConfig, timeout_seconds: 61 };
|
|
211
|
+
const result = validateConfig(config);
|
|
212
|
+
|
|
213
|
+
expect(result.valid).toBe(false);
|
|
214
|
+
expect(result.errors.join('\n')).toMatch(/timeout_seconds must not exceed 60 seconds/);
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
test('returns unknown validation error for non-yup errors', () => {
|
|
218
|
+
// We need to force validateSync to throw a non-yup error.
|
|
219
|
+
// We can do this by passing an object with a valueOf that throws.
|
|
220
|
+
const badConfig = {
|
|
221
|
+
get provider() {
|
|
222
|
+
throw new TypeError('unexpected');
|
|
223
|
+
},
|
|
224
|
+
};
|
|
225
|
+
|
|
226
|
+
const result = validateConfig(badConfig);
|
|
227
|
+
|
|
228
|
+
expect(result.valid).toBe(false);
|
|
229
|
+
expect(result.errors).toEqual(['Unknown validation error']);
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
test('accepts ignore_patterns as a valid optional array', () => {
|
|
233
|
+
const config = { ...mockConfig, ignore_patterns: ['*.md', '*.json'] };
|
|
234
|
+
const result = validateConfig(config);
|
|
235
|
+
|
|
236
|
+
expect(result.valid).toBe(true);
|
|
237
|
+
expect(result.errors).toHaveLength(0);
|
|
238
|
+
});
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
describe('getDefaultConfigPath', () => {
|
|
242
|
+
test('returns cadr.yaml', () => {
|
|
243
|
+
const result = getDefaultConfigPath();
|
|
244
|
+
|
|
245
|
+
expect(result).toBe('cadr.yaml');
|
|
246
|
+
});
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
describe('createConfig', () => {
|
|
250
|
+
function mockReadline(answers: string[]) {
|
|
251
|
+
let callIndex = 0;
|
|
252
|
+
const mockRl = {
|
|
253
|
+
question: jest.fn((_prompt: string, callback: (answer: string) => void) => {
|
|
254
|
+
callback(answers[callIndex] || '');
|
|
255
|
+
callIndex++;
|
|
256
|
+
}),
|
|
257
|
+
close: jest.fn(),
|
|
258
|
+
};
|
|
259
|
+
(readline.createInterface as jest.Mock).mockReturnValue(mockRl);
|
|
260
|
+
return mockRl;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
beforeEach(() => {
|
|
264
|
+
// yaml.dump needs to return a string for writeFileSync
|
|
265
|
+
(yaml.dump as jest.Mock).mockReturnValue('provider: openai\n');
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
test('creates config with openai defaults and writes to file', async () => {
|
|
269
|
+
// Answers: provider=openai, model=gpt-4, api_key_env=OPENAI_API_KEY, timeout=15, ignore_patterns=*.md
|
|
270
|
+
const mockRl = mockReadline(['openai', 'gpt-4', 'OPENAI_API_KEY', '15', '*.md']);
|
|
271
|
+
|
|
272
|
+
const result = await createConfig('/test/cadr.yaml');
|
|
273
|
+
|
|
274
|
+
expect(result).not.toBeNull();
|
|
275
|
+
expect(result!.provider).toBe('openai');
|
|
276
|
+
expect(result!.analysis_model).toBe('gpt-4');
|
|
277
|
+
expect(result!.api_key_env).toBe('OPENAI_API_KEY');
|
|
278
|
+
expect(result!.timeout_seconds).toBe(15);
|
|
279
|
+
expect(result!.ignore_patterns).toEqual(['*.md']);
|
|
280
|
+
expect(fs.writeFileSync).toHaveBeenCalledWith('/test/cadr.yaml', expect.any(String), 'utf-8');
|
|
281
|
+
expect(mockRl.close).toHaveBeenCalled();
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
test('creates config with gemini defaults when gemini provider is chosen', async () => {
|
|
285
|
+
// Answer provider=gemini, then use defaults for the rest
|
|
286
|
+
mockReadline(['gemini', '', '', '15', '']);
|
|
287
|
+
|
|
288
|
+
// yaml.dump needs to be realistic for this call
|
|
289
|
+
(yaml.dump as jest.Mock).mockReturnValue('provider: gemini\n');
|
|
290
|
+
|
|
291
|
+
const result = await createConfig('/test/cadr.yaml');
|
|
292
|
+
|
|
293
|
+
expect(result).not.toBeNull();
|
|
294
|
+
expect(result!.provider).toBe('gemini');
|
|
295
|
+
expect(result!.analysis_model).toBe('gemini-1.5-pro');
|
|
296
|
+
expect(result!.api_key_env).toBe('GEMINI_API_KEY');
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
test('returns null when validation fails during createConfig', async () => {
|
|
300
|
+
// Use an invalid provider to trigger validation failure
|
|
301
|
+
mockReadline(['invalid_provider', 'model', 'KEY', '15', '']);
|
|
302
|
+
|
|
303
|
+
const result = await createConfig('/test/cadr.yaml');
|
|
304
|
+
|
|
305
|
+
expect(result).toBeNull();
|
|
306
|
+
expect(fs.writeFileSync).not.toHaveBeenCalled();
|
|
307
|
+
});
|
|
308
|
+
});
|
|
79
309
|
});
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { GitError } from './git.errors';
|
|
2
|
+
|
|
3
|
+
describe('GitError', () => {
|
|
4
|
+
it('should set message, code, and name for NOT_GIT_REPO', () => {
|
|
5
|
+
const error = new GitError('msg', 'NOT_GIT_REPO');
|
|
6
|
+
|
|
7
|
+
expect(error.message).toBe('msg');
|
|
8
|
+
expect(error.code).toBe('NOT_GIT_REPO');
|
|
9
|
+
expect(error.name).toBe('GitError');
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it('should set code to GIT_NOT_FOUND', () => {
|
|
13
|
+
const error = new GitError('msg', 'GIT_NOT_FOUND');
|
|
14
|
+
|
|
15
|
+
expect(error.code).toBe('GIT_NOT_FOUND');
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('should set code to GIT_ERROR', () => {
|
|
19
|
+
const error = new GitError('msg', 'GIT_ERROR');
|
|
20
|
+
|
|
21
|
+
expect(error.code).toBe('GIT_ERROR');
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('should store originalError when provided', () => {
|
|
25
|
+
const original = new Error('original');
|
|
26
|
+
const error = new GitError('msg', 'GIT_ERROR', original);
|
|
27
|
+
|
|
28
|
+
expect(error.originalError).toBe(original);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('should have undefined originalError when not provided', () => {
|
|
32
|
+
const error = new GitError('msg', 'GIT_ERROR');
|
|
33
|
+
|
|
34
|
+
expect(error.originalError).toBeUndefined();
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('should be an instance of both Error and GitError', () => {
|
|
38
|
+
const error = new GitError('msg', 'NOT_GIT_REPO');
|
|
39
|
+
|
|
40
|
+
expect(error instanceof Error).toBe(true);
|
|
41
|
+
expect(error instanceof GitError).toBe(true);
|
|
42
|
+
});
|
|
43
|
+
});
|