mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-01-30 06:12:03 +00:00
- Introduced a new function, buildThinkingOptions, to handle the conversion of ThinkingLevel to maxThinkingTokens for the Claude SDK. - Updated existing SDK option creation functions to incorporate thinking options, ensuring that maxThinkingTokens are included based on the specified thinking level. - Enhanced the settings service to support migration of phase models to include thinking levels, improving compatibility with new configurations. - Added comprehensive tests for thinking level integration and migration logic, ensuring robust functionality across the application. This update significantly improves the SDK's configurability and performance by allowing for more nuanced control over reasoning capabilities.
784 lines
28 KiB
TypeScript
784 lines
28 KiB
TypeScript
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
import fs from 'fs/promises';
|
|
import path from 'path';
|
|
import os from 'os';
|
|
import { SettingsService } from '@/services/settings-service.js';
|
|
import {
|
|
DEFAULT_GLOBAL_SETTINGS,
|
|
DEFAULT_CREDENTIALS,
|
|
DEFAULT_PROJECT_SETTINGS,
|
|
SETTINGS_VERSION,
|
|
CREDENTIALS_VERSION,
|
|
PROJECT_SETTINGS_VERSION,
|
|
type GlobalSettings,
|
|
type Credentials,
|
|
type ProjectSettings,
|
|
} from '@/types/settings.js';
|
|
|
|
describe('settings-service.ts', () => {
|
|
let testDataDir: string;
|
|
let testProjectDir: string;
|
|
let settingsService: SettingsService;
|
|
|
|
beforeEach(async () => {
|
|
testDataDir = path.join(os.tmpdir(), `settings-test-${Date.now()}`);
|
|
testProjectDir = path.join(os.tmpdir(), `project-test-${Date.now()}`);
|
|
await fs.mkdir(testDataDir, { recursive: true });
|
|
await fs.mkdir(testProjectDir, { recursive: true });
|
|
settingsService = new SettingsService(testDataDir);
|
|
});
|
|
|
|
afterEach(async () => {
|
|
try {
|
|
await fs.rm(testDataDir, { recursive: true, force: true });
|
|
await fs.rm(testProjectDir, { recursive: true, force: true });
|
|
} catch {
|
|
// Ignore cleanup errors
|
|
}
|
|
});
|
|
|
|
describe('getGlobalSettings', () => {
|
|
it('should return default settings when file does not exist', async () => {
|
|
const settings = await settingsService.getGlobalSettings();
|
|
expect(settings).toEqual(DEFAULT_GLOBAL_SETTINGS);
|
|
});
|
|
|
|
it('should read and return existing settings', async () => {
|
|
const customSettings: GlobalSettings = {
|
|
...DEFAULT_GLOBAL_SETTINGS,
|
|
theme: 'light',
|
|
sidebarOpen: false,
|
|
maxConcurrency: 5,
|
|
};
|
|
const settingsPath = path.join(testDataDir, 'settings.json');
|
|
await fs.writeFile(settingsPath, JSON.stringify(customSettings, null, 2));
|
|
|
|
const settings = await settingsService.getGlobalSettings();
|
|
expect(settings.theme).toBe('light');
|
|
expect(settings.sidebarOpen).toBe(false);
|
|
expect(settings.maxConcurrency).toBe(5);
|
|
});
|
|
|
|
it('should merge with defaults for missing properties', async () => {
|
|
const partialSettings = {
|
|
version: SETTINGS_VERSION,
|
|
theme: 'dark',
|
|
};
|
|
const settingsPath = path.join(testDataDir, 'settings.json');
|
|
await fs.writeFile(settingsPath, JSON.stringify(partialSettings, null, 2));
|
|
|
|
const settings = await settingsService.getGlobalSettings();
|
|
expect(settings.theme).toBe('dark');
|
|
expect(settings.sidebarOpen).toBe(DEFAULT_GLOBAL_SETTINGS.sidebarOpen);
|
|
expect(settings.maxConcurrency).toBe(DEFAULT_GLOBAL_SETTINGS.maxConcurrency);
|
|
});
|
|
|
|
it('should merge keyboard shortcuts deeply', async () => {
|
|
const customSettings: GlobalSettings = {
|
|
...DEFAULT_GLOBAL_SETTINGS,
|
|
keyboardShortcuts: {
|
|
...DEFAULT_GLOBAL_SETTINGS.keyboardShortcuts,
|
|
board: 'B',
|
|
},
|
|
};
|
|
const settingsPath = path.join(testDataDir, 'settings.json');
|
|
await fs.writeFile(settingsPath, JSON.stringify(customSettings, null, 2));
|
|
|
|
const settings = await settingsService.getGlobalSettings();
|
|
expect(settings.keyboardShortcuts.board).toBe('B');
|
|
expect(settings.keyboardShortcuts.agent).toBe(
|
|
DEFAULT_GLOBAL_SETTINGS.keyboardShortcuts.agent
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('updateGlobalSettings', () => {
|
|
it('should create settings file with updates', async () => {
|
|
const updates: Partial<GlobalSettings> = {
|
|
theme: 'light',
|
|
sidebarOpen: false,
|
|
};
|
|
|
|
const updated = await settingsService.updateGlobalSettings(updates);
|
|
|
|
expect(updated.theme).toBe('light');
|
|
expect(updated.sidebarOpen).toBe(false);
|
|
expect(updated.version).toBe(SETTINGS_VERSION);
|
|
|
|
const settingsPath = path.join(testDataDir, 'settings.json');
|
|
const fileContent = await fs.readFile(settingsPath, 'utf-8');
|
|
const saved = JSON.parse(fileContent);
|
|
expect(saved.theme).toBe('light');
|
|
expect(saved.sidebarOpen).toBe(false);
|
|
});
|
|
|
|
it('should merge updates with existing settings', async () => {
|
|
const initial: GlobalSettings = {
|
|
...DEFAULT_GLOBAL_SETTINGS,
|
|
theme: 'dark',
|
|
maxConcurrency: 3,
|
|
};
|
|
const settingsPath = path.join(testDataDir, 'settings.json');
|
|
await fs.writeFile(settingsPath, JSON.stringify(initial, null, 2));
|
|
|
|
const updates: Partial<GlobalSettings> = {
|
|
theme: 'light',
|
|
};
|
|
|
|
const updated = await settingsService.updateGlobalSettings(updates);
|
|
|
|
expect(updated.theme).toBe('light');
|
|
expect(updated.maxConcurrency).toBe(3); // Preserved from initial
|
|
});
|
|
|
|
it('should deep merge keyboard shortcuts', async () => {
|
|
const updates: Partial<GlobalSettings> = {
|
|
keyboardShortcuts: {
|
|
board: 'B',
|
|
},
|
|
};
|
|
|
|
const updated = await settingsService.updateGlobalSettings(updates);
|
|
|
|
expect(updated.keyboardShortcuts.board).toBe('B');
|
|
expect(updated.keyboardShortcuts.agent).toBe(DEFAULT_GLOBAL_SETTINGS.keyboardShortcuts.agent);
|
|
});
|
|
|
|
it('should create data directory if it does not exist', async () => {
|
|
const newDataDir = path.join(os.tmpdir(), `new-data-dir-${Date.now()}`);
|
|
const newService = new SettingsService(newDataDir);
|
|
|
|
await newService.updateGlobalSettings({ theme: 'light' });
|
|
|
|
const stats = await fs.stat(newDataDir);
|
|
expect(stats.isDirectory()).toBe(true);
|
|
|
|
await fs.rm(newDataDir, { recursive: true, force: true });
|
|
});
|
|
});
|
|
|
|
describe('hasGlobalSettings', () => {
|
|
it('should return false when settings file does not exist', async () => {
|
|
const exists = await settingsService.hasGlobalSettings();
|
|
expect(exists).toBe(false);
|
|
});
|
|
|
|
it('should return true when settings file exists', async () => {
|
|
await settingsService.updateGlobalSettings({ theme: 'light' });
|
|
const exists = await settingsService.hasGlobalSettings();
|
|
expect(exists).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('getCredentials', () => {
|
|
it('should return default credentials when file does not exist', async () => {
|
|
const credentials = await settingsService.getCredentials();
|
|
expect(credentials).toEqual(DEFAULT_CREDENTIALS);
|
|
});
|
|
|
|
it('should read and return existing credentials', async () => {
|
|
const customCredentials: Credentials = {
|
|
...DEFAULT_CREDENTIALS,
|
|
apiKeys: {
|
|
anthropic: 'sk-test-key',
|
|
},
|
|
};
|
|
const credentialsPath = path.join(testDataDir, 'credentials.json');
|
|
await fs.writeFile(credentialsPath, JSON.stringify(customCredentials, null, 2));
|
|
|
|
const credentials = await settingsService.getCredentials();
|
|
expect(credentials.apiKeys.anthropic).toBe('sk-test-key');
|
|
});
|
|
|
|
it('should merge with defaults for missing api keys', async () => {
|
|
const partialCredentials = {
|
|
version: CREDENTIALS_VERSION,
|
|
apiKeys: {
|
|
anthropic: 'sk-test',
|
|
},
|
|
};
|
|
const credentialsPath = path.join(testDataDir, 'credentials.json');
|
|
await fs.writeFile(credentialsPath, JSON.stringify(partialCredentials, null, 2));
|
|
|
|
const credentials = await settingsService.getCredentials();
|
|
expect(credentials.apiKeys.anthropic).toBe('sk-test');
|
|
});
|
|
});
|
|
|
|
describe('updateCredentials', () => {
|
|
it('should create credentials file with updates', async () => {
|
|
const updates: Partial<Credentials> = {
|
|
apiKeys: {
|
|
anthropic: 'sk-test-key',
|
|
},
|
|
};
|
|
|
|
const updated = await settingsService.updateCredentials(updates);
|
|
|
|
expect(updated.apiKeys.anthropic).toBe('sk-test-key');
|
|
expect(updated.version).toBe(CREDENTIALS_VERSION);
|
|
|
|
const credentialsPath = path.join(testDataDir, 'credentials.json');
|
|
const fileContent = await fs.readFile(credentialsPath, 'utf-8');
|
|
const saved = JSON.parse(fileContent);
|
|
expect(saved.apiKeys.anthropic).toBe('sk-test-key');
|
|
});
|
|
|
|
it('should merge updates with existing credentials', async () => {
|
|
const initial: Credentials = {
|
|
...DEFAULT_CREDENTIALS,
|
|
apiKeys: {
|
|
anthropic: 'sk-initial',
|
|
},
|
|
};
|
|
const credentialsPath = path.join(testDataDir, 'credentials.json');
|
|
await fs.writeFile(credentialsPath, JSON.stringify(initial, null, 2));
|
|
|
|
const updates: Partial<Credentials> = {
|
|
apiKeys: {
|
|
anthropic: 'sk-updated',
|
|
},
|
|
};
|
|
|
|
const updated = await settingsService.updateCredentials(updates);
|
|
|
|
expect(updated.apiKeys.anthropic).toBe('sk-updated');
|
|
});
|
|
|
|
it('should deep merge api keys', async () => {
|
|
const initial: Credentials = {
|
|
...DEFAULT_CREDENTIALS,
|
|
apiKeys: {
|
|
anthropic: 'sk-anthropic',
|
|
},
|
|
};
|
|
const credentialsPath = path.join(testDataDir, 'credentials.json');
|
|
await fs.writeFile(credentialsPath, JSON.stringify(initial, null, 2));
|
|
|
|
const updates: Partial<Credentials> = {
|
|
apiKeys: {
|
|
anthropic: 'sk-updated-anthropic',
|
|
},
|
|
};
|
|
|
|
const updated = await settingsService.updateCredentials(updates);
|
|
|
|
expect(updated.apiKeys.anthropic).toBe('sk-updated-anthropic');
|
|
});
|
|
});
|
|
|
|
describe('getMaskedCredentials', () => {
|
|
it('should return masked credentials for empty keys', async () => {
|
|
const masked = await settingsService.getMaskedCredentials();
|
|
expect(masked.anthropic.configured).toBe(false);
|
|
expect(masked.anthropic.masked).toBe('');
|
|
});
|
|
|
|
it('should mask keys correctly', async () => {
|
|
await settingsService.updateCredentials({
|
|
apiKeys: {
|
|
anthropic: 'sk-ant-api03-1234567890abcdef',
|
|
},
|
|
});
|
|
|
|
const masked = await settingsService.getMaskedCredentials();
|
|
expect(masked.anthropic.configured).toBe(true);
|
|
expect(masked.anthropic.masked).toBe('sk-a...cdef');
|
|
});
|
|
|
|
it('should handle short keys', async () => {
|
|
await settingsService.updateCredentials({
|
|
apiKeys: {
|
|
anthropic: 'short',
|
|
},
|
|
});
|
|
|
|
const masked = await settingsService.getMaskedCredentials();
|
|
expect(masked.anthropic.configured).toBe(true);
|
|
expect(masked.anthropic.masked).toBe('');
|
|
});
|
|
});
|
|
|
|
describe('hasCredentials', () => {
|
|
it('should return false when credentials file does not exist', async () => {
|
|
const exists = await settingsService.hasCredentials();
|
|
expect(exists).toBe(false);
|
|
});
|
|
|
|
it('should return true when credentials file exists', async () => {
|
|
await settingsService.updateCredentials({
|
|
apiKeys: { anthropic: 'test' },
|
|
});
|
|
const exists = await settingsService.hasCredentials();
|
|
expect(exists).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('getProjectSettings', () => {
|
|
it('should return default settings when file does not exist', async () => {
|
|
const settings = await settingsService.getProjectSettings(testProjectDir);
|
|
expect(settings).toEqual(DEFAULT_PROJECT_SETTINGS);
|
|
});
|
|
|
|
it('should read and return existing project settings', async () => {
|
|
const customSettings: ProjectSettings = {
|
|
...DEFAULT_PROJECT_SETTINGS,
|
|
theme: 'light',
|
|
useWorktrees: true,
|
|
};
|
|
const automakerDir = path.join(testProjectDir, '.automaker');
|
|
await fs.mkdir(automakerDir, { recursive: true });
|
|
const settingsPath = path.join(automakerDir, 'settings.json');
|
|
await fs.writeFile(settingsPath, JSON.stringify(customSettings, null, 2));
|
|
|
|
const settings = await settingsService.getProjectSettings(testProjectDir);
|
|
expect(settings.theme).toBe('light');
|
|
expect(settings.useWorktrees).toBe(true);
|
|
});
|
|
|
|
it('should merge with defaults for missing properties', async () => {
|
|
const partialSettings = {
|
|
version: PROJECT_SETTINGS_VERSION,
|
|
theme: 'dark',
|
|
};
|
|
const automakerDir = path.join(testProjectDir, '.automaker');
|
|
await fs.mkdir(automakerDir, { recursive: true });
|
|
const settingsPath = path.join(automakerDir, 'settings.json');
|
|
await fs.writeFile(settingsPath, JSON.stringify(partialSettings, null, 2));
|
|
|
|
const settings = await settingsService.getProjectSettings(testProjectDir);
|
|
expect(settings.theme).toBe('dark');
|
|
expect(settings.version).toBe(PROJECT_SETTINGS_VERSION);
|
|
});
|
|
});
|
|
|
|
describe('updateProjectSettings', () => {
|
|
it('should create project settings file with updates', async () => {
|
|
const updates: Partial<ProjectSettings> = {
|
|
theme: 'light',
|
|
useWorktrees: true,
|
|
};
|
|
|
|
const updated = await settingsService.updateProjectSettings(testProjectDir, updates);
|
|
|
|
expect(updated.theme).toBe('light');
|
|
expect(updated.useWorktrees).toBe(true);
|
|
expect(updated.version).toBe(PROJECT_SETTINGS_VERSION);
|
|
|
|
const automakerDir = path.join(testProjectDir, '.automaker');
|
|
const settingsPath = path.join(automakerDir, 'settings.json');
|
|
const fileContent = await fs.readFile(settingsPath, 'utf-8');
|
|
const saved = JSON.parse(fileContent);
|
|
expect(saved.theme).toBe('light');
|
|
expect(saved.useWorktrees).toBe(true);
|
|
});
|
|
|
|
it('should merge updates with existing project settings', async () => {
|
|
const initial: ProjectSettings = {
|
|
...DEFAULT_PROJECT_SETTINGS,
|
|
theme: 'dark',
|
|
useWorktrees: false,
|
|
};
|
|
const automakerDir = path.join(testProjectDir, '.automaker');
|
|
await fs.mkdir(automakerDir, { recursive: true });
|
|
const settingsPath = path.join(automakerDir, 'settings.json');
|
|
await fs.writeFile(settingsPath, JSON.stringify(initial, null, 2));
|
|
|
|
const updates: Partial<ProjectSettings> = {
|
|
theme: 'light',
|
|
};
|
|
|
|
const updated = await settingsService.updateProjectSettings(testProjectDir, updates);
|
|
|
|
expect(updated.theme).toBe('light');
|
|
expect(updated.useWorktrees).toBe(false); // Preserved
|
|
});
|
|
|
|
it('should deep merge board background', async () => {
|
|
const initial: ProjectSettings = {
|
|
...DEFAULT_PROJECT_SETTINGS,
|
|
boardBackground: {
|
|
imagePath: '/path/to/image.jpg',
|
|
cardOpacity: 0.8,
|
|
columnOpacity: 0.9,
|
|
columnBorderEnabled: true,
|
|
cardGlassmorphism: false,
|
|
cardBorderEnabled: true,
|
|
cardBorderOpacity: 0.5,
|
|
hideScrollbar: false,
|
|
},
|
|
};
|
|
const automakerDir = path.join(testProjectDir, '.automaker');
|
|
await fs.mkdir(automakerDir, { recursive: true });
|
|
const settingsPath = path.join(automakerDir, 'settings.json');
|
|
await fs.writeFile(settingsPath, JSON.stringify(initial, null, 2));
|
|
|
|
const updates: Partial<ProjectSettings> = {
|
|
boardBackground: {
|
|
cardOpacity: 0.9,
|
|
},
|
|
};
|
|
|
|
const updated = await settingsService.updateProjectSettings(testProjectDir, updates);
|
|
|
|
expect(updated.boardBackground?.imagePath).toBe('/path/to/image.jpg');
|
|
expect(updated.boardBackground?.cardOpacity).toBe(0.9);
|
|
expect(updated.boardBackground?.columnOpacity).toBe(0.9);
|
|
});
|
|
|
|
it('should create .automaker directory if it does not exist', async () => {
|
|
const newProjectDir = path.join(os.tmpdir(), `new-project-${Date.now()}`);
|
|
|
|
await settingsService.updateProjectSettings(newProjectDir, { theme: 'light' });
|
|
|
|
const automakerDir = path.join(newProjectDir, '.automaker');
|
|
const stats = await fs.stat(automakerDir);
|
|
expect(stats.isDirectory()).toBe(true);
|
|
|
|
await fs.rm(newProjectDir, { recursive: true, force: true });
|
|
});
|
|
});
|
|
|
|
describe('hasProjectSettings', () => {
|
|
it('should return false when project settings file does not exist', async () => {
|
|
const exists = await settingsService.hasProjectSettings(testProjectDir);
|
|
expect(exists).toBe(false);
|
|
});
|
|
|
|
it('should return true when project settings file exists', async () => {
|
|
await settingsService.updateProjectSettings(testProjectDir, { theme: 'light' });
|
|
const exists = await settingsService.hasProjectSettings(testProjectDir);
|
|
expect(exists).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('migrateFromLocalStorage', () => {
|
|
it('should migrate global settings from localStorage data', async () => {
|
|
const localStorageData = {
|
|
'automaker-storage': JSON.stringify({
|
|
state: {
|
|
theme: 'light',
|
|
sidebarOpen: false,
|
|
maxConcurrency: 5,
|
|
},
|
|
}),
|
|
};
|
|
|
|
const result = await settingsService.migrateFromLocalStorage(localStorageData);
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(result.migratedGlobalSettings).toBe(true);
|
|
expect(result.migratedCredentials).toBe(false);
|
|
expect(result.migratedProjectCount).toBe(0);
|
|
|
|
const settings = await settingsService.getGlobalSettings();
|
|
expect(settings.theme).toBe('light');
|
|
expect(settings.sidebarOpen).toBe(false);
|
|
expect(settings.maxConcurrency).toBe(5);
|
|
});
|
|
|
|
it('should migrate credentials from localStorage data', async () => {
|
|
const localStorageData = {
|
|
'automaker-storage': JSON.stringify({
|
|
state: {
|
|
apiKeys: {
|
|
anthropic: 'sk-test-key',
|
|
},
|
|
},
|
|
}),
|
|
};
|
|
|
|
const result = await settingsService.migrateFromLocalStorage(localStorageData);
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(result.migratedCredentials).toBe(true);
|
|
|
|
const credentials = await settingsService.getCredentials();
|
|
expect(credentials.apiKeys.anthropic).toBe('sk-test-key');
|
|
});
|
|
|
|
it('should migrate project settings from localStorage data', async () => {
|
|
const localStorageData = {
|
|
'automaker-storage': JSON.stringify({
|
|
state: {
|
|
projects: [
|
|
{
|
|
id: 'proj1',
|
|
name: 'Project 1',
|
|
path: testProjectDir,
|
|
theme: 'light',
|
|
},
|
|
],
|
|
boardBackgroundByProject: {
|
|
[testProjectDir]: {
|
|
imagePath: '/path/to/image.jpg',
|
|
cardOpacity: 0.8,
|
|
columnOpacity: 0.9,
|
|
columnBorderEnabled: true,
|
|
cardGlassmorphism: false,
|
|
cardBorderEnabled: true,
|
|
cardBorderOpacity: 0.5,
|
|
hideScrollbar: false,
|
|
},
|
|
},
|
|
},
|
|
}),
|
|
};
|
|
|
|
const result = await settingsService.migrateFromLocalStorage(localStorageData);
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(result.migratedProjectCount).toBe(1);
|
|
|
|
const projectSettings = await settingsService.getProjectSettings(testProjectDir);
|
|
expect(projectSettings.theme).toBe('light');
|
|
expect(projectSettings.boardBackground?.imagePath).toBe('/path/to/image.jpg');
|
|
});
|
|
|
|
it('should handle direct localStorage values', async () => {
|
|
const localStorageData = {
|
|
'automaker:lastProjectDir': '/path/to/project',
|
|
'file-browser-recent-folders': JSON.stringify(['/path1', '/path2']),
|
|
'worktree-panel-collapsed': 'true',
|
|
};
|
|
|
|
const result = await settingsService.migrateFromLocalStorage(localStorageData);
|
|
|
|
expect(result.success).toBe(true);
|
|
const settings = await settingsService.getGlobalSettings();
|
|
expect(settings.lastProjectDir).toBe('/path/to/project');
|
|
expect(settings.recentFolders).toEqual(['/path1', '/path2']);
|
|
expect(settings.worktreePanelCollapsed).toBe(true);
|
|
});
|
|
|
|
it('should handle invalid JSON gracefully', async () => {
|
|
const localStorageData = {
|
|
'automaker-storage': 'invalid json',
|
|
'file-browser-recent-folders': 'invalid json',
|
|
};
|
|
|
|
const result = await settingsService.migrateFromLocalStorage(localStorageData);
|
|
|
|
expect(result.success).toBe(false);
|
|
expect(result.errors.length).toBeGreaterThan(0);
|
|
});
|
|
|
|
// Skip on Windows as chmod doesn't work the same way (CI runs on Linux)
|
|
it.skipIf(process.platform === 'win32')(
|
|
'should handle migration errors gracefully',
|
|
async () => {
|
|
// Create a read-only directory to cause write errors
|
|
const readOnlyDir = path.join(os.tmpdir(), `readonly-${Date.now()}`);
|
|
await fs.mkdir(readOnlyDir, { recursive: true });
|
|
await fs.chmod(readOnlyDir, 0o444);
|
|
|
|
const readOnlyService = new SettingsService(readOnlyDir);
|
|
const localStorageData = {
|
|
'automaker-storage': JSON.stringify({
|
|
state: { theme: 'light' },
|
|
}),
|
|
};
|
|
|
|
const result = await readOnlyService.migrateFromLocalStorage(localStorageData);
|
|
|
|
expect(result.success).toBe(false);
|
|
expect(result.errors.length).toBeGreaterThan(0);
|
|
|
|
await fs.chmod(readOnlyDir, 0o755);
|
|
await fs.rm(readOnlyDir, { recursive: true, force: true });
|
|
}
|
|
);
|
|
});
|
|
|
|
describe('getDataDir', () => {
|
|
it('should return the data directory path', () => {
|
|
const dataDir = settingsService.getDataDir();
|
|
expect(dataDir).toBe(testDataDir);
|
|
});
|
|
});
|
|
|
|
describe('phase model migration (v2 -> v3)', () => {
|
|
it('should migrate string phase models to PhaseModelEntry format', async () => {
|
|
// Simulate v2 format with string phase models
|
|
const v2Settings = {
|
|
version: 2,
|
|
theme: 'dark',
|
|
phaseModels: {
|
|
enhancementModel: 'sonnet',
|
|
fileDescriptionModel: 'haiku',
|
|
imageDescriptionModel: 'haiku',
|
|
validationModel: 'sonnet',
|
|
specGenerationModel: 'opus',
|
|
featureGenerationModel: 'sonnet',
|
|
backlogPlanningModel: 'sonnet',
|
|
projectAnalysisModel: 'sonnet',
|
|
},
|
|
};
|
|
const settingsPath = path.join(testDataDir, 'settings.json');
|
|
await fs.writeFile(settingsPath, JSON.stringify(v2Settings, null, 2));
|
|
|
|
const settings = await settingsService.getGlobalSettings();
|
|
|
|
// Verify all phase models are now PhaseModelEntry objects
|
|
expect(settings.phaseModels.enhancementModel).toEqual({ model: 'sonnet' });
|
|
expect(settings.phaseModels.fileDescriptionModel).toEqual({ model: 'haiku' });
|
|
expect(settings.phaseModels.specGenerationModel).toEqual({ model: 'opus' });
|
|
expect(settings.version).toBe(SETTINGS_VERSION);
|
|
});
|
|
|
|
it('should preserve PhaseModelEntry objects during migration', async () => {
|
|
// Simulate v3 format (already has PhaseModelEntry objects)
|
|
const v3Settings = {
|
|
version: 3,
|
|
theme: 'dark',
|
|
phaseModels: {
|
|
enhancementModel: { model: 'sonnet', thinkingLevel: 'high' },
|
|
fileDescriptionModel: { model: 'haiku' },
|
|
imageDescriptionModel: { model: 'haiku', thinkingLevel: 'low' },
|
|
validationModel: { model: 'sonnet' },
|
|
specGenerationModel: { model: 'opus', thinkingLevel: 'ultrathink' },
|
|
featureGenerationModel: { model: 'sonnet' },
|
|
backlogPlanningModel: { model: 'sonnet', thinkingLevel: 'medium' },
|
|
projectAnalysisModel: { model: 'sonnet' },
|
|
},
|
|
};
|
|
const settingsPath = path.join(testDataDir, 'settings.json');
|
|
await fs.writeFile(settingsPath, JSON.stringify(v3Settings, null, 2));
|
|
|
|
const settings = await settingsService.getGlobalSettings();
|
|
|
|
// Verify PhaseModelEntry objects are preserved with thinkingLevel
|
|
expect(settings.phaseModels.enhancementModel).toEqual({
|
|
model: 'sonnet',
|
|
thinkingLevel: 'high',
|
|
});
|
|
expect(settings.phaseModels.specGenerationModel).toEqual({
|
|
model: 'opus',
|
|
thinkingLevel: 'ultrathink',
|
|
});
|
|
expect(settings.phaseModels.backlogPlanningModel).toEqual({
|
|
model: 'sonnet',
|
|
thinkingLevel: 'medium',
|
|
});
|
|
});
|
|
|
|
it('should handle mixed format (some string, some object)', async () => {
|
|
// Edge case: mixed format (shouldn't happen but handle gracefully)
|
|
const mixedSettings = {
|
|
version: 2,
|
|
theme: 'dark',
|
|
phaseModels: {
|
|
enhancementModel: 'sonnet', // string
|
|
fileDescriptionModel: { model: 'haiku', thinkingLevel: 'low' }, // object
|
|
imageDescriptionModel: 'haiku', // string
|
|
validationModel: { model: 'opus' }, // object without thinkingLevel
|
|
specGenerationModel: 'opus',
|
|
featureGenerationModel: 'sonnet',
|
|
backlogPlanningModel: 'sonnet',
|
|
projectAnalysisModel: 'sonnet',
|
|
},
|
|
};
|
|
const settingsPath = path.join(testDataDir, 'settings.json');
|
|
await fs.writeFile(settingsPath, JSON.stringify(mixedSettings, null, 2));
|
|
|
|
const settings = await settingsService.getGlobalSettings();
|
|
|
|
// Strings should be converted to objects
|
|
expect(settings.phaseModels.enhancementModel).toEqual({ model: 'sonnet' });
|
|
expect(settings.phaseModels.imageDescriptionModel).toEqual({ model: 'haiku' });
|
|
// Objects should be preserved
|
|
expect(settings.phaseModels.fileDescriptionModel).toEqual({
|
|
model: 'haiku',
|
|
thinkingLevel: 'low',
|
|
});
|
|
expect(settings.phaseModels.validationModel).toEqual({ model: 'opus' });
|
|
});
|
|
|
|
it('should migrate legacy enhancementModel/validationModel fields', async () => {
|
|
// Simulate v1 format with legacy fields
|
|
const v1Settings = {
|
|
version: 1,
|
|
theme: 'dark',
|
|
enhancementModel: 'haiku',
|
|
validationModel: 'opus',
|
|
// No phaseModels object
|
|
};
|
|
const settingsPath = path.join(testDataDir, 'settings.json');
|
|
await fs.writeFile(settingsPath, JSON.stringify(v1Settings, null, 2));
|
|
|
|
const settings = await settingsService.getGlobalSettings();
|
|
|
|
// Legacy fields should be migrated to phaseModels
|
|
expect(settings.phaseModels.enhancementModel).toEqual({ model: 'haiku' });
|
|
expect(settings.phaseModels.validationModel).toEqual({ model: 'opus' });
|
|
// Other fields should use defaults
|
|
expect(settings.phaseModels.specGenerationModel).toEqual({ model: 'opus' });
|
|
});
|
|
|
|
it('should use default phase models when none are configured', async () => {
|
|
// Simulate empty settings
|
|
const emptySettings = {
|
|
version: 1,
|
|
theme: 'dark',
|
|
};
|
|
const settingsPath = path.join(testDataDir, 'settings.json');
|
|
await fs.writeFile(settingsPath, JSON.stringify(emptySettings, null, 2));
|
|
|
|
const settings = await settingsService.getGlobalSettings();
|
|
|
|
// Should use DEFAULT_PHASE_MODELS
|
|
expect(settings.phaseModels.enhancementModel).toEqual({ model: 'sonnet' });
|
|
expect(settings.phaseModels.fileDescriptionModel).toEqual({ model: 'haiku' });
|
|
expect(settings.phaseModels.specGenerationModel).toEqual({ model: 'opus' });
|
|
});
|
|
|
|
it('should deep merge phaseModels on update', async () => {
|
|
// Create initial settings with some phase models
|
|
await settingsService.updateGlobalSettings({
|
|
phaseModels: {
|
|
enhancementModel: { model: 'sonnet', thinkingLevel: 'high' },
|
|
},
|
|
});
|
|
|
|
// Update with a different phase model
|
|
await settingsService.updateGlobalSettings({
|
|
phaseModels: {
|
|
specGenerationModel: { model: 'opus', thinkingLevel: 'ultrathink' },
|
|
},
|
|
});
|
|
|
|
const settings = await settingsService.getGlobalSettings();
|
|
|
|
// Both should be preserved
|
|
expect(settings.phaseModels.enhancementModel).toEqual({
|
|
model: 'sonnet',
|
|
thinkingLevel: 'high',
|
|
});
|
|
expect(settings.phaseModels.specGenerationModel).toEqual({
|
|
model: 'opus',
|
|
thinkingLevel: 'ultrathink',
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('atomicWriteJson', () => {
|
|
// Skip on Windows as chmod doesn't work the same way (CI runs on Linux)
|
|
it.skipIf(process.platform === 'win32')(
|
|
'should handle write errors and clean up temp file',
|
|
async () => {
|
|
// Create a read-only directory to cause write errors
|
|
const readOnlyDir = path.join(os.tmpdir(), `readonly-${Date.now()}`);
|
|
await fs.mkdir(readOnlyDir, { recursive: true });
|
|
await fs.chmod(readOnlyDir, 0o444);
|
|
|
|
const readOnlyService = new SettingsService(readOnlyDir);
|
|
|
|
await expect(readOnlyService.updateGlobalSettings({ theme: 'light' })).rejects.toThrow();
|
|
|
|
await fs.chmod(readOnlyDir, 0o755);
|
|
await fs.rm(readOnlyDir, { recursive: true, force: true });
|
|
}
|
|
);
|
|
});
|
|
});
|