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 = { 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 = { 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 = { 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", google: "", openai: "", }, }; 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"); expect(credentials.apiKeys.google).toBe(""); expect(credentials.apiKeys.openai).toBe(""); }); }); describe("updateCredentials", () => { it("should create credentials file with updates", async () => { const updates: Partial = { apiKeys: { anthropic: "sk-test-key", google: "", openai: "", }, }; 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", google: "google-key", openai: "", }, }; const credentialsPath = path.join(testDataDir, "credentials.json"); await fs.writeFile(credentialsPath, JSON.stringify(initial, null, 2)); const updates: Partial = { apiKeys: { anthropic: "sk-updated", }, }; const updated = await settingsService.updateCredentials(updates); expect(updated.apiKeys.anthropic).toBe("sk-updated"); expect(updated.apiKeys.google).toBe("google-key"); // Preserved }); it("should deep merge api keys", async () => { const initial: Credentials = { ...DEFAULT_CREDENTIALS, apiKeys: { anthropic: "sk-anthropic", google: "google-key", openai: "openai-key", }, }; const credentialsPath = path.join(testDataDir, "credentials.json"); await fs.writeFile(credentialsPath, JSON.stringify(initial, null, 2)); const updates: Partial = { apiKeys: { openai: "new-openai-key", }, }; const updated = await settingsService.updateCredentials(updates); expect(updated.apiKeys.anthropic).toBe("sk-anthropic"); expect(updated.apiKeys.google).toBe("google-key"); expect(updated.apiKeys.openai).toBe("new-openai-key"); }); }); 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(""); expect(masked.google.configured).toBe(false); expect(masked.openai.configured).toBe(false); }); it("should mask keys correctly", async () => { await settingsService.updateCredentials({ apiKeys: { anthropic: "sk-ant-api03-1234567890abcdef", google: "AIzaSy1234567890abcdef", openai: "sk-1234567890abcdef", }, }); const masked = await settingsService.getMaskedCredentials(); expect(masked.anthropic.configured).toBe(true); expect(masked.anthropic.masked).toBe("sk-a...cdef"); expect(masked.google.configured).toBe(true); expect(masked.google.masked).toBe("AIza...cdef"); expect(masked.openai.configured).toBe(true); expect(masked.openai.masked).toBe("sk-1...cdef"); }); it("should handle short keys", async () => { await settingsService.updateCredentials({ apiKeys: { anthropic: "short", google: "", openai: "", }, }); 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", google: "", openai: "" }, }); 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 = { 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 = { 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 = { 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", google: "google-key", openai: "openai-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"); expect(credentials.apiKeys.google).toBe("google-key"); expect(credentials.apiKeys.openai).toBe("openai-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); }); it("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("atomicWriteJson", () => { it("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 }); }); }); });