gsd-opencode 1.20.1 → 1.20.3
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/commands/gsd/gsd-check-profile.md +30 -0
- package/commands/gsd/gsd-research-phase.md +2 -2
- package/get-shit-done/bin/gsd-oc-commands/check-oc-config-json.cjs +169 -0
- package/get-shit-done/bin/gsd-oc-commands/check-opencode-json.cjs +86 -0
- package/get-shit-done/bin/gsd-oc-commands/get-profile.cjs +117 -0
- package/get-shit-done/bin/gsd-oc-commands/set-profile.cjs +357 -0
- package/get-shit-done/bin/gsd-oc-commands/update-opencode-json.cjs +199 -0
- package/get-shit-done/bin/gsd-oc-commands/validate-models.cjs +75 -0
- package/get-shit-done/bin/gsd-oc-lib/oc-config.cjs +205 -0
- package/get-shit-done/bin/gsd-oc-lib/oc-core.cjs +113 -0
- package/get-shit-done/bin/gsd-oc-lib/oc-models.cjs +133 -0
- package/get-shit-done/bin/gsd-oc-lib/oc-profile-config.cjs +409 -0
- package/get-shit-done/bin/gsd-oc-tools.cjs +130 -0
- package/get-shit-done/bin/lib/oc-config.cjs +200 -0
- package/get-shit-done/bin/lib/oc-core.cjs +114 -0
- package/get-shit-done/bin/lib/oc-models.cjs +133 -0
- package/get-shit-done/bin/test/fixtures/oc-config-invalid.json +14 -0
- package/get-shit-done/bin/test/fixtures/oc-config-valid.json +22 -0
- package/get-shit-done/bin/test/get-profile.test.cjs +447 -0
- package/get-shit-done/bin/test/oc-profile-config.test.cjs +377 -0
- package/get-shit-done/bin/test/pivot-profile.test.cjs +276 -0
- package/get-shit-done/bin/test/set-profile.test.cjs +301 -0
- package/get-shit-done/workflows/diagnose-issues.md +1 -1
- package/get-shit-done/workflows/discuss-phase.md +1 -1
- package/get-shit-done/workflows/new-project.md +4 -4
- package/get-shit-done/workflows/oc-check-profile.md +181 -0
- package/get-shit-done/workflows/oc-set-profile.md +83 -243
- package/get-shit-done/workflows/plan-phase.md +4 -4
- package/get-shit-done/workflows/quick.md +1 -1
- package/get-shit-done/workflows/settings.md +4 -3
- package/package.json +2 -2
|
@@ -0,0 +1,377 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for oc-profile-config.cjs
|
|
3
|
+
*
|
|
4
|
+
* Tests for loadOcProfileConfig, validateProfile, and applyProfileWithValidation
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
8
|
+
import fs from 'fs';
|
|
9
|
+
import path from 'path';
|
|
10
|
+
import os from 'os';
|
|
11
|
+
|
|
12
|
+
import {
|
|
13
|
+
loadOcProfileConfig,
|
|
14
|
+
validateProfile,
|
|
15
|
+
applyProfileWithValidation,
|
|
16
|
+
getAgentsForProfile,
|
|
17
|
+
ERROR_CODES
|
|
18
|
+
} from '../gsd-oc-lib/oc-profile-config.cjs';
|
|
19
|
+
|
|
20
|
+
// Test fixtures
|
|
21
|
+
import VALID_CONFIG from './fixtures/oc-config-valid.json' assert { type: 'json' };
|
|
22
|
+
import INVALID_CONFIG from './fixtures/oc-config-invalid.json' assert { type: 'json' };
|
|
23
|
+
|
|
24
|
+
// Mock model catalog (simulates opencode models output)
|
|
25
|
+
const MOCK_MODELS = [
|
|
26
|
+
'bailian-coding-plan/qwen3.5-plus',
|
|
27
|
+
'bailian-coding-plan/qwen3.5-pro',
|
|
28
|
+
'opencode/gpt-5-nano',
|
|
29
|
+
'kilo/anthropic/claude-3.7-sonnet',
|
|
30
|
+
'kilo/anthropic/claude-3.5-haiku'
|
|
31
|
+
];
|
|
32
|
+
|
|
33
|
+
describe('oc-profile-config.cjs', () => {
|
|
34
|
+
let testDir;
|
|
35
|
+
let planningDir;
|
|
36
|
+
let configPath;
|
|
37
|
+
|
|
38
|
+
beforeEach(() => {
|
|
39
|
+
// Create isolated test directory
|
|
40
|
+
testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'oc-profile-test-'));
|
|
41
|
+
planningDir = path.join(testDir, '.planning');
|
|
42
|
+
configPath = path.join(planningDir, 'oc_config.json');
|
|
43
|
+
fs.mkdirSync(planningDir, { recursive: true });
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
afterEach(() => {
|
|
47
|
+
// Cleanup test directory
|
|
48
|
+
try {
|
|
49
|
+
fs.rmSync(testDir, { recursive: true, force: true });
|
|
50
|
+
} catch (err) {
|
|
51
|
+
// Ignore cleanup errors
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
describe('loadOcProfileConfig', () => {
|
|
56
|
+
it('returns CONFIG_NOT_FOUND when file does not exist', () => {
|
|
57
|
+
const result = loadOcProfileConfig(testDir);
|
|
58
|
+
|
|
59
|
+
expect(result.success).toBe(false);
|
|
60
|
+
expect(result.error.code).toBe(ERROR_CODES.CONFIG_NOT_FOUND);
|
|
61
|
+
expect(result.error.message).toContain('oc_config.json not found');
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('returns INVALID_JSON for malformed JSON', () => {
|
|
65
|
+
fs.writeFileSync(configPath, '{ invalid json }', 'utf8');
|
|
66
|
+
|
|
67
|
+
const result = loadOcProfileConfig(testDir);
|
|
68
|
+
|
|
69
|
+
expect(result.success).toBe(false);
|
|
70
|
+
expect(result.error.code).toBe(ERROR_CODES.INVALID_JSON);
|
|
71
|
+
expect(result.error.message).toContain('Invalid JSON');
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('returns config and configPath for valid file', () => {
|
|
75
|
+
fs.writeFileSync(configPath, JSON.stringify(VALID_CONFIG, null, 2), 'utf8');
|
|
76
|
+
|
|
77
|
+
const result = loadOcProfileConfig(testDir);
|
|
78
|
+
|
|
79
|
+
expect(result.success).toBe(true);
|
|
80
|
+
expect(result.config).toEqual(VALID_CONFIG);
|
|
81
|
+
expect(result.configPath).toBe(configPath);
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
describe('validateProfile', () => {
|
|
86
|
+
it('returns valid: true for existing profile with valid models', () => {
|
|
87
|
+
const result = validateProfile(VALID_CONFIG, 'simple', MOCK_MODELS);
|
|
88
|
+
|
|
89
|
+
expect(result.valid).toBe(true);
|
|
90
|
+
expect(result.errors).toHaveLength(0);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('returns PROFILE_NOT_FOUND for non-existent profile', () => {
|
|
94
|
+
const result = validateProfile(VALID_CONFIG, 'nonexistent', MOCK_MODELS);
|
|
95
|
+
|
|
96
|
+
expect(result.valid).toBe(false);
|
|
97
|
+
expect(result.errors).toHaveLength(1);
|
|
98
|
+
expect(result.errors[0].code).toBe(ERROR_CODES.PROFILE_NOT_FOUND);
|
|
99
|
+
expect(result.errors[0].message).toContain('not found');
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('returns INVALID_MODELS for profile with invalid model IDs', () => {
|
|
103
|
+
const result = validateProfile(INVALID_CONFIG, 'invalid-models', MOCK_MODELS);
|
|
104
|
+
|
|
105
|
+
expect(result.valid).toBe(false);
|
|
106
|
+
expect(result.errors).toHaveLength(1);
|
|
107
|
+
expect(result.errors[0].code).toBe(ERROR_CODES.INVALID_MODELS);
|
|
108
|
+
expect(result.errors[0].invalidModels).toHaveLength(3);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('returns INCOMPLETE_PROFILE for missing planning/execution/verification', () => {
|
|
112
|
+
const result = validateProfile(INVALID_CONFIG, 'incomplete', MOCK_MODELS);
|
|
113
|
+
|
|
114
|
+
expect(result.valid).toBe(false);
|
|
115
|
+
expect(result.errors).toHaveLength(1);
|
|
116
|
+
expect(result.errors[0].code).toBe(ERROR_CODES.INCOMPLETE_PROFILE);
|
|
117
|
+
expect(result.errors[0].missingKeys).toContain('execution');
|
|
118
|
+
expect(result.errors[0].missingKeys).toContain('verification');
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
describe('applyProfileWithValidation', () => {
|
|
123
|
+
it('dry-run mode returns preview without file modifications', () => {
|
|
124
|
+
// Setup opencode.json for applyProfileToOpencode to work
|
|
125
|
+
const opencodePath = path.join(testDir, 'opencode.json');
|
|
126
|
+
fs.writeFileSync(opencodePath, JSON.stringify({
|
|
127
|
+
"$schema": "https://opencode.ai/config.json",
|
|
128
|
+
"agent": {}
|
|
129
|
+
}, null, 2), 'utf8');
|
|
130
|
+
|
|
131
|
+
// Write config file
|
|
132
|
+
fs.writeFileSync(configPath, JSON.stringify(VALID_CONFIG, null, 2), 'utf8');
|
|
133
|
+
|
|
134
|
+
const result = applyProfileWithValidation(testDir, 'smart', {
|
|
135
|
+
dryRun: true,
|
|
136
|
+
verbose: false
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
expect(result.success).toBe(true);
|
|
140
|
+
expect(result.dryRun).toBe(true);
|
|
141
|
+
expect(result.preview).toBeDefined();
|
|
142
|
+
expect(result.preview.profile).toBe('smart');
|
|
143
|
+
expect(result.preview.models).toHaveProperty('planning');
|
|
144
|
+
expect(result.preview.models).toHaveProperty('execution');
|
|
145
|
+
expect(result.preview.models).toHaveProperty('verification');
|
|
146
|
+
|
|
147
|
+
// Verify no backup was created in dry-run
|
|
148
|
+
const backupDir = path.join(testDir, '.planning', 'backups');
|
|
149
|
+
expect(fs.existsSync(backupDir)).toBe(false);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it('creates backups before modifications', () => {
|
|
153
|
+
// Setup opencode.json
|
|
154
|
+
const opencodePath = path.join(testDir, 'opencode.json');
|
|
155
|
+
fs.writeFileSync(opencodePath, JSON.stringify({
|
|
156
|
+
"$schema": "https://opencode.ai/config.json",
|
|
157
|
+
"agent": {}
|
|
158
|
+
}, null, 2), 'utf8');
|
|
159
|
+
|
|
160
|
+
// Write config file
|
|
161
|
+
fs.writeFileSync(configPath, JSON.stringify(VALID_CONFIG, null, 2), 'utf8');
|
|
162
|
+
|
|
163
|
+
const result = applyProfileWithValidation(testDir, 'simple', {
|
|
164
|
+
dryRun: false,
|
|
165
|
+
verbose: false
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
expect(result.success).toBe(true);
|
|
169
|
+
expect(result.data.backup).toBeDefined();
|
|
170
|
+
expect(fs.existsSync(result.data.backup)).toBe(true);
|
|
171
|
+
expect(result.data.backup).toContain('.planning/backups');
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it('updates oc_config.json with current_oc_profile', () => {
|
|
175
|
+
// Setup initial config with different current profile
|
|
176
|
+
const initialConfig = {
|
|
177
|
+
current_oc_profile: 'simple',
|
|
178
|
+
profiles: VALID_CONFIG.profiles
|
|
179
|
+
};
|
|
180
|
+
fs.writeFileSync(configPath, JSON.stringify(initialConfig, null, 2), 'utf8');
|
|
181
|
+
|
|
182
|
+
// Setup opencode.json
|
|
183
|
+
const opencodePath = path.join(testDir, 'opencode.json');
|
|
184
|
+
fs.writeFileSync(opencodePath, JSON.stringify({
|
|
185
|
+
"$schema": "https://opencode.ai/config.json",
|
|
186
|
+
"agent": {}
|
|
187
|
+
}, null, 2), 'utf8');
|
|
188
|
+
|
|
189
|
+
const result = applyProfileWithValidation(testDir, 'genius', {
|
|
190
|
+
dryRun: false,
|
|
191
|
+
verbose: false
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
expect(result.success).toBe(true);
|
|
195
|
+
|
|
196
|
+
// Verify config was updated
|
|
197
|
+
const updatedConfig = JSON.parse(fs.readFileSync(configPath, 'utf8'));
|
|
198
|
+
expect(updatedConfig.current_oc_profile).toBe('genius');
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it('applies to opencode.json via applyProfileToOpencode', () => {
|
|
202
|
+
// Setup opencode.json
|
|
203
|
+
const opencodePath = path.join(testDir, 'opencode.json');
|
|
204
|
+
fs.writeFileSync(opencodePath, JSON.stringify({
|
|
205
|
+
"$schema": "https://opencode.ai/config.json",
|
|
206
|
+
"agent": {}
|
|
207
|
+
}, null, 2), 'utf8');
|
|
208
|
+
|
|
209
|
+
fs.writeFileSync(configPath, JSON.stringify(VALID_CONFIG, null, 2), 'utf8');
|
|
210
|
+
|
|
211
|
+
const result = applyProfileWithValidation(testDir, 'smart', {
|
|
212
|
+
dryRun: false,
|
|
213
|
+
verbose: false
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
expect(result.success).toBe(true);
|
|
217
|
+
expect(result.data.updated).toBeDefined();
|
|
218
|
+
expect(Array.isArray(result.data.updated)).toBe(true);
|
|
219
|
+
|
|
220
|
+
// Verify opencode.json was updated with gsd-* agents
|
|
221
|
+
const updatedOpencode = JSON.parse(fs.readFileSync(opencodePath, 'utf8'));
|
|
222
|
+
expect(updatedOpencode.agent).toBeDefined();
|
|
223
|
+
expect(updatedOpencode.agent['gsd-planner']).toBeDefined();
|
|
224
|
+
expect(updatedOpencode.agent['gsd-executor']).toBeDefined();
|
|
225
|
+
expect(updatedOpencode.agent['gsd-verifier']).toBeDefined();
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
it('returns error for non-existent profile', () => {
|
|
229
|
+
fs.writeFileSync(configPath, JSON.stringify(VALID_CONFIG, null, 2), 'utf8');
|
|
230
|
+
|
|
231
|
+
const result = applyProfileWithValidation(testDir, 'nonexistent', {
|
|
232
|
+
dryRun: false,
|
|
233
|
+
verbose: false
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
expect(result.success).toBe(false);
|
|
237
|
+
expect(result.error.code).toBe(ERROR_CODES.PROFILE_NOT_FOUND);
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
it('validates models before file modifications', () => {
|
|
241
|
+
// Config with invalid models
|
|
242
|
+
const invalidConfig = {
|
|
243
|
+
current_oc_profile: 'simple',
|
|
244
|
+
profiles: {
|
|
245
|
+
presets: {
|
|
246
|
+
'bad-profile': {
|
|
247
|
+
planning: 'invalid-model',
|
|
248
|
+
execution: 'invalid-model',
|
|
249
|
+
verification: 'invalid-model'
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
};
|
|
254
|
+
fs.writeFileSync(configPath, JSON.stringify(invalidConfig, null, 2), 'utf8');
|
|
255
|
+
|
|
256
|
+
const result = applyProfileWithValidation(testDir, 'bad-profile', {
|
|
257
|
+
dryRun: false,
|
|
258
|
+
verbose: false
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
expect(result.success).toBe(false);
|
|
262
|
+
expect(result.error.code).toBe(ERROR_CODES.INVALID_MODELS);
|
|
263
|
+
|
|
264
|
+
// Verify config was NOT modified (validation happened first)
|
|
265
|
+
const configAfter = JSON.parse(fs.readFileSync(configPath, 'utf8'));
|
|
266
|
+
expect(configAfter.current_oc_profile).toBe('simple');
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
it('supports inline profile definition', () => {
|
|
270
|
+
// Setup opencode.json
|
|
271
|
+
const opencodePath = path.join(testDir, 'opencode.json');
|
|
272
|
+
fs.writeFileSync(opencodePath, JSON.stringify({
|
|
273
|
+
"$schema": "https://opencode.ai/config.json",
|
|
274
|
+
"agent": {}
|
|
275
|
+
}, null, 2), 'utf8');
|
|
276
|
+
|
|
277
|
+
// Start with empty profiles
|
|
278
|
+
const initialConfig = {
|
|
279
|
+
profiles: {
|
|
280
|
+
presets: {}
|
|
281
|
+
}
|
|
282
|
+
};
|
|
283
|
+
fs.writeFileSync(configPath, JSON.stringify(initialConfig, null, 2), 'utf8');
|
|
284
|
+
|
|
285
|
+
const inlineProfile = {
|
|
286
|
+
planning: 'bailian-coding-plan/qwen3.5-plus',
|
|
287
|
+
execution: 'bailian-coding-plan/qwen3.5-plus',
|
|
288
|
+
verification: 'bailian-coding-plan/qwen3.5-plus'
|
|
289
|
+
};
|
|
290
|
+
|
|
291
|
+
const result = applyProfileWithValidation(testDir, 'custom', {
|
|
292
|
+
dryRun: false,
|
|
293
|
+
verbose: false,
|
|
294
|
+
inlineProfile
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
expect(result.success).toBe(true);
|
|
298
|
+
|
|
299
|
+
// Verify profile was added
|
|
300
|
+
const updatedConfig = JSON.parse(fs.readFileSync(configPath, 'utf8'));
|
|
301
|
+
expect(updatedConfig.profiles.presets.custom).toEqual(inlineProfile);
|
|
302
|
+
expect(updatedConfig.current_oc_profile).toBe('custom');
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
it('rejects incomplete inline profile definition', () => {
|
|
306
|
+
fs.writeFileSync(configPath, JSON.stringify(VALID_CONFIG, null, 2), 'utf8');
|
|
307
|
+
|
|
308
|
+
const incompleteProfile = {
|
|
309
|
+
planning: 'bailian-coding-plan/qwen3.5-plus'
|
|
310
|
+
// Missing execution and verification
|
|
311
|
+
};
|
|
312
|
+
|
|
313
|
+
const result = applyProfileWithValidation(testDir, 'new-profile', {
|
|
314
|
+
dryRun: false,
|
|
315
|
+
verbose: false,
|
|
316
|
+
inlineProfile: incompleteProfile
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
expect(result.success).toBe(false);
|
|
320
|
+
expect(result.error.code).toBe(ERROR_CODES.INCOMPLETE_PROFILE);
|
|
321
|
+
expect(result.error.missingKeys).toContain('execution');
|
|
322
|
+
});
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
describe('getAgentsForProfile', () => {
|
|
326
|
+
it('returns all agents for complete profile', () => {
|
|
327
|
+
const profile = {
|
|
328
|
+
planning: 'bailian-coding-plan/qwen3.5-plus',
|
|
329
|
+
execution: 'opencode/gpt-5-nano',
|
|
330
|
+
verification: 'kilo/anthropic/claude-3.7-sonnet'
|
|
331
|
+
};
|
|
332
|
+
|
|
333
|
+
const agents = getAgentsForProfile(profile);
|
|
334
|
+
|
|
335
|
+
expect(agents).toBeInstanceOf(Array);
|
|
336
|
+
expect(agents.length).toBeGreaterThan(10); // Should have 11 agents
|
|
337
|
+
|
|
338
|
+
// Check planning agents
|
|
339
|
+
const planningAgents = agents.filter(a => a.model === 'bailian-coding-plan/qwen3.5-plus');
|
|
340
|
+
expect(planningAgents.length).toBe(7);
|
|
341
|
+
|
|
342
|
+
// Check execution agents
|
|
343
|
+
const executionAgents = agents.filter(a => a.model === 'opencode/gpt-5-nano');
|
|
344
|
+
expect(executionAgents.length).toBe(2);
|
|
345
|
+
|
|
346
|
+
// Check verification agents
|
|
347
|
+
const verificationAgents = agents.filter(a => a.model === 'kilo/anthropic/claude-3.7-sonnet');
|
|
348
|
+
expect(verificationAgents.length).toBe(2);
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
it('handles profile with missing categories', () => {
|
|
352
|
+
const profile = {
|
|
353
|
+
planning: 'bailian-coding-plan/qwen3.5-plus'
|
|
354
|
+
// Missing execution and verification
|
|
355
|
+
};
|
|
356
|
+
|
|
357
|
+
const agents = getAgentsForProfile(profile);
|
|
358
|
+
|
|
359
|
+
expect(agents).toBeInstanceOf(Array);
|
|
360
|
+
expect(agents.length).toBe(7); // Only planning agents
|
|
361
|
+
expect(agents.every(a => a.model === 'bailian-coding-plan/qwen3.5-plus')).toBe(true);
|
|
362
|
+
});
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
describe('ERROR_CODES', () => {
|
|
366
|
+
it('exports all expected error codes', () => {
|
|
367
|
+
expect(ERROR_CODES).toHaveProperty('CONFIG_NOT_FOUND');
|
|
368
|
+
expect(ERROR_CODES).toHaveProperty('INVALID_JSON');
|
|
369
|
+
expect(ERROR_CODES).toHaveProperty('PROFILE_NOT_FOUND');
|
|
370
|
+
expect(ERROR_CODES).toHaveProperty('INVALID_MODELS');
|
|
371
|
+
expect(ERROR_CODES).toHaveProperty('INCOMPLETE_PROFILE');
|
|
372
|
+
expect(ERROR_CODES).toHaveProperty('WRITE_FAILED');
|
|
373
|
+
expect(ERROR_CODES).toHaveProperty('APPLY_FAILED');
|
|
374
|
+
expect(ERROR_CODES).toHaveProperty('ROLLBACK_FAILED');
|
|
375
|
+
});
|
|
376
|
+
});
|
|
377
|
+
});
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for pivot-profile.cjs
|
|
3
|
+
*
|
|
4
|
+
* Tests for the thin wrapper that delegates to setProfilePhase16
|
|
5
|
+
* Focus: Verify correct import and delegation, not re-testing underlying functionality
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
9
|
+
import fs from 'fs';
|
|
10
|
+
import path from 'path';
|
|
11
|
+
import os from 'os';
|
|
12
|
+
|
|
13
|
+
// Mock console.log and console.error to capture output
|
|
14
|
+
const originalLog = console.log;
|
|
15
|
+
const originalError = console.error;
|
|
16
|
+
const originalExit = process.exit;
|
|
17
|
+
|
|
18
|
+
// Test fixtures
|
|
19
|
+
const VALID_CONFIG = {
|
|
20
|
+
current_oc_profile: 'smart',
|
|
21
|
+
profiles: {
|
|
22
|
+
presets: {
|
|
23
|
+
simple: {
|
|
24
|
+
planning: 'bailian-coding-plan/qwen3.5-plus',
|
|
25
|
+
execution: 'bailian-coding-plan/qwen3.5-plus',
|
|
26
|
+
verification: 'bailian-coding-plan/qwen3.5-plus'
|
|
27
|
+
},
|
|
28
|
+
smart: {
|
|
29
|
+
planning: 'bailian-coding-plan/qwen3.5-plus',
|
|
30
|
+
execution: 'bailian-coding-plan/qwen3.5-plus',
|
|
31
|
+
verification: 'bailian-coding-plan/qwen3.5-plus'
|
|
32
|
+
},
|
|
33
|
+
genius: {
|
|
34
|
+
planning: 'bailian-coding-plan/qwen3.5-plus',
|
|
35
|
+
execution: 'bailian-coding-plan/qwen3.5-plus',
|
|
36
|
+
verification: 'bailian-coding-plan/qwen3.5-plus'
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
describe('pivot-profile.cjs', () => {
|
|
43
|
+
let testDir;
|
|
44
|
+
let planningDir;
|
|
45
|
+
let configPath;
|
|
46
|
+
let opencodePath;
|
|
47
|
+
let capturedLog;
|
|
48
|
+
let capturedError;
|
|
49
|
+
let exitCode;
|
|
50
|
+
let allLogs;
|
|
51
|
+
let allErrors;
|
|
52
|
+
|
|
53
|
+
beforeEach(() => {
|
|
54
|
+
// Create isolated test directory
|
|
55
|
+
testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pivot-profile-test-'));
|
|
56
|
+
planningDir = path.join(testDir, '.planning');
|
|
57
|
+
configPath = path.join(planningDir, 'oc_config.json');
|
|
58
|
+
opencodePath = path.join(testDir, 'opencode.json');
|
|
59
|
+
|
|
60
|
+
fs.mkdirSync(planningDir, { recursive: true });
|
|
61
|
+
|
|
62
|
+
// Reset captured output
|
|
63
|
+
capturedLog = null;
|
|
64
|
+
capturedError = null;
|
|
65
|
+
exitCode = null;
|
|
66
|
+
allLogs = [];
|
|
67
|
+
allErrors = [];
|
|
68
|
+
|
|
69
|
+
// Mock console.log to capture all output
|
|
70
|
+
console.log = (msg) => {
|
|
71
|
+
allLogs.push(msg);
|
|
72
|
+
capturedLog = msg;
|
|
73
|
+
};
|
|
74
|
+
console.error = (msg) => {
|
|
75
|
+
allErrors.push(msg);
|
|
76
|
+
capturedError = msg;
|
|
77
|
+
};
|
|
78
|
+
process.exit = (code) => {
|
|
79
|
+
exitCode = code;
|
|
80
|
+
throw new Error(`process.exit(${code})`);
|
|
81
|
+
};
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
afterEach(() => {
|
|
85
|
+
// Restore original functions
|
|
86
|
+
console.log = originalLog;
|
|
87
|
+
console.error = originalError;
|
|
88
|
+
process.exit = originalExit;
|
|
89
|
+
|
|
90
|
+
// Cleanup test directory
|
|
91
|
+
try {
|
|
92
|
+
fs.rmSync(testDir, { recursive: true, force: true });
|
|
93
|
+
} catch (err) {
|
|
94
|
+
// Ignore cleanup errors
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
// Import pivotProfile inside tests to use mocked functions
|
|
99
|
+
const importPivotProfile = () => {
|
|
100
|
+
const modulePath = '../gsd-oc-commands/pivot-profile.cjs';
|
|
101
|
+
delete require.cache[require.resolve(modulePath)];
|
|
102
|
+
return require(modulePath);
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
describe('Export verification', () => {
|
|
106
|
+
it('exports pivotProfile function', () => {
|
|
107
|
+
const pivotProfile = importPivotProfile();
|
|
108
|
+
expect(typeof pivotProfile).toBe('function');
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('function name is pivotProfile', () => {
|
|
112
|
+
const pivotProfile = importPivotProfile();
|
|
113
|
+
expect(pivotProfile.name).toBe('pivotProfile');
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
describe('Delegation tests', () => {
|
|
118
|
+
function writeOpencodeJson() {
|
|
119
|
+
const opencode = {
|
|
120
|
+
$schema: 'https://opencode.ai/schema.json',
|
|
121
|
+
agent: {
|
|
122
|
+
'gsd-planner': {
|
|
123
|
+
model: 'bailian-coding-plan/qwen3.5-plus',
|
|
124
|
+
tools: ['*']
|
|
125
|
+
},
|
|
126
|
+
'gsd-executor': {
|
|
127
|
+
model: 'bailian-coding-plan/qwen3.5-plus',
|
|
128
|
+
tools: ['*']
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
};
|
|
132
|
+
fs.writeFileSync(opencodePath, JSON.stringify(opencode, null, 2) + '\n', 'utf8');
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
beforeEach(() => {
|
|
136
|
+
fs.writeFileSync(configPath, JSON.stringify(VALID_CONFIG, null, 2) + '\n', 'utf8');
|
|
137
|
+
writeOpencodeJson();
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it('pivotProfile delegates to setProfilePhase16', () => {
|
|
141
|
+
const pivotProfile = importPivotProfile();
|
|
142
|
+
|
|
143
|
+
try {
|
|
144
|
+
pivotProfile(testDir, ['smart']);
|
|
145
|
+
} catch (err) {
|
|
146
|
+
// Expected to throw due to process.exit mock
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
expect(exitCode).toBe(0);
|
|
150
|
+
const output = JSON.parse(capturedLog);
|
|
151
|
+
expect(output.success).toBe(true);
|
|
152
|
+
expect(output.data.profile).toBe('smart');
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it('pivotProfile accepts cwd and args parameters', () => {
|
|
156
|
+
const pivotProfile = importPivotProfile();
|
|
157
|
+
|
|
158
|
+
// Should not throw except for process.exit mock
|
|
159
|
+
try {
|
|
160
|
+
pivotProfile(testDir, ['smart']);
|
|
161
|
+
} catch (err) {
|
|
162
|
+
// Expected - only process.exit should throw
|
|
163
|
+
expect(err.message).toContain('process.exit');
|
|
164
|
+
}
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it('pivotProfile passes arguments through unchanged', () => {
|
|
168
|
+
const pivotProfile = importPivotProfile();
|
|
169
|
+
|
|
170
|
+
try {
|
|
171
|
+
pivotProfile(testDir, ['genius']);
|
|
172
|
+
} catch (err) {
|
|
173
|
+
// Expected
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const output = JSON.parse(capturedLog);
|
|
177
|
+
expect(output.data.profile).toBe('genius');
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it('pivotProfile returns same output structure as setProfilePhase16', () => {
|
|
181
|
+
const pivotProfile = importPivotProfile();
|
|
182
|
+
|
|
183
|
+
try {
|
|
184
|
+
pivotProfile(testDir, ['simple']);
|
|
185
|
+
} catch (err) {
|
|
186
|
+
// Expected
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const output = JSON.parse(capturedLog);
|
|
190
|
+
expect(output).toHaveProperty('success', true);
|
|
191
|
+
expect(output.data).toHaveProperty('profile');
|
|
192
|
+
expect(output.data).toHaveProperty('models');
|
|
193
|
+
expect(output.data.models).toHaveProperty('planning');
|
|
194
|
+
expect(output.data.models).toHaveProperty('execution');
|
|
195
|
+
expect(output.data.models).toHaveProperty('verification');
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it('pivotProfile handles --dry-run flag', () => {
|
|
199
|
+
const pivotProfile = importPivotProfile();
|
|
200
|
+
|
|
201
|
+
try {
|
|
202
|
+
pivotProfile(testDir, ['--dry-run', 'genius']);
|
|
203
|
+
} catch (err) {
|
|
204
|
+
// Expected
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
expect(exitCode).toBe(0);
|
|
208
|
+
const output = JSON.parse(capturedLog);
|
|
209
|
+
expect(output.success).toBe(true);
|
|
210
|
+
expect(output.data.dryRun).toBe(true);
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it('pivotProfile returns error for invalid profile', () => {
|
|
214
|
+
const pivotProfile = importPivotProfile();
|
|
215
|
+
|
|
216
|
+
try {
|
|
217
|
+
pivotProfile(testDir, ['nonexistent']);
|
|
218
|
+
} catch (err) {
|
|
219
|
+
// Expected
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
expect(exitCode).toBe(1);
|
|
223
|
+
const error = JSON.parse(capturedError);
|
|
224
|
+
expect(error.error.code).toBe('PROFILE_NOT_FOUND');
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
it('pivotProfile works in Mode 1 (no profile name)', () => {
|
|
228
|
+
const configWithCurrent = {
|
|
229
|
+
...VALID_CONFIG,
|
|
230
|
+
current_oc_profile: 'smart'
|
|
231
|
+
};
|
|
232
|
+
fs.writeFileSync(configPath, JSON.stringify(configWithCurrent, null, 2) + '\n', 'utf8');
|
|
233
|
+
|
|
234
|
+
const pivotProfile = importPivotProfile();
|
|
235
|
+
|
|
236
|
+
try {
|
|
237
|
+
pivotProfile(testDir, []);
|
|
238
|
+
} catch (err) {
|
|
239
|
+
// Expected
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
expect(exitCode).toBe(0);
|
|
243
|
+
const output = JSON.parse(capturedLog);
|
|
244
|
+
expect(output.success).toBe(true);
|
|
245
|
+
expect(output.data.profile).toBe('smart');
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
it('pivotProfile handles inline profile creation (Mode 3)', () => {
|
|
249
|
+
const pivotProfile = importPivotProfile();
|
|
250
|
+
const profileDef = 'test:{"planning":"bailian-coding-plan/qwen3.5-plus","execution":"bailian-coding-plan/qwen3.5-plus","verification":"bailian-coding-plan/qwen3.5-plus"}';
|
|
251
|
+
|
|
252
|
+
try {
|
|
253
|
+
pivotProfile(testDir, [profileDef]);
|
|
254
|
+
} catch (err) {
|
|
255
|
+
// Expected
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
expect(exitCode).toBe(0);
|
|
259
|
+
const output = JSON.parse(capturedLog);
|
|
260
|
+
expect(output.success).toBe(true);
|
|
261
|
+
expect(output.data.profile).toBe('test');
|
|
262
|
+
|
|
263
|
+
// Verify profile was added to config
|
|
264
|
+
const updatedConfig = JSON.parse(fs.readFileSync(configPath, 'utf8'));
|
|
265
|
+
expect(updatedConfig.profiles.presets.test).toBeDefined();
|
|
266
|
+
expect(updatedConfig.current_oc_profile).toBe('test');
|
|
267
|
+
});
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
describe('Integration with gsd-oc-tools.cjs', () => {
|
|
271
|
+
it('pivot-profile module can be imported', () => {
|
|
272
|
+
const pivotProfile = importPivotProfile();
|
|
273
|
+
expect(typeof pivotProfile).toBe('function');
|
|
274
|
+
});
|
|
275
|
+
});
|
|
276
|
+
});
|