Enhance unit tests for settings service and error handling

- Add comprehensive unit tests for SettingsService, covering global and project settings management, including creation, updates, and merging with defaults.
- Implement tests for handling credentials, ensuring proper masking and merging of API keys.
- Introduce tests for migration from localStorage, validating successful data transfer and error handling.
- Enhance error handling in subprocess management tests, ensuring robust timeout and output reading scenarios.
This commit is contained in:
Cody Seibert
2025-12-20 09:03:32 -05:00
parent ace736c7c2
commit c76ba691a4
8 changed files with 1019 additions and 3 deletions

View File

@@ -13,6 +13,10 @@ import {
getAppSpecPath,
getBranchTrackingPath,
ensureAutomakerDir,
getGlobalSettingsPath,
getCredentialsPath,
getProjectSettingsPath,
ensureDataDir,
} from "@/lib/automaker-paths.js";
describe("automaker-paths.ts", () => {
@@ -136,4 +140,91 @@ describe("automaker-paths.ts", () => {
expect(result).toBe(automakerDir);
});
});
describe("getGlobalSettingsPath", () => {
it("should return path to settings.json in data directory", () => {
const dataDir = "/test/data";
const result = getGlobalSettingsPath(dataDir);
expect(result).toBe(path.join(dataDir, "settings.json"));
});
it("should handle paths with trailing slashes", () => {
const dataDir = "/test/data" + path.sep;
const result = getGlobalSettingsPath(dataDir);
expect(result).toBe(path.join(dataDir, "settings.json"));
});
});
describe("getCredentialsPath", () => {
it("should return path to credentials.json in data directory", () => {
const dataDir = "/test/data";
const result = getCredentialsPath(dataDir);
expect(result).toBe(path.join(dataDir, "credentials.json"));
});
it("should handle paths with trailing slashes", () => {
const dataDir = "/test/data" + path.sep;
const result = getCredentialsPath(dataDir);
expect(result).toBe(path.join(dataDir, "credentials.json"));
});
});
describe("getProjectSettingsPath", () => {
it("should return path to settings.json in project .automaker directory", () => {
const projectPath = "/test/project";
const result = getProjectSettingsPath(projectPath);
expect(result).toBe(
path.join(projectPath, ".automaker", "settings.json")
);
});
it("should handle paths with trailing slashes", () => {
const projectPath = "/test/project" + path.sep;
const result = getProjectSettingsPath(projectPath);
expect(result).toBe(
path.join(projectPath, ".automaker", "settings.json")
);
});
});
describe("ensureDataDir", () => {
let testDir: string;
beforeEach(async () => {
testDir = path.join(os.tmpdir(), `data-dir-test-${Date.now()}`);
});
afterEach(async () => {
try {
await fs.rm(testDir, { recursive: true, force: true });
} catch {
// Ignore cleanup errors
}
});
it("should create data directory and return path", async () => {
const result = await ensureDataDir(testDir);
expect(result).toBe(testDir);
const stats = await fs.stat(testDir);
expect(stats.isDirectory()).toBe(true);
});
it("should succeed if directory already exists", async () => {
await fs.mkdir(testDir, { recursive: true });
const result = await ensureDataDir(testDir);
expect(result).toBe(testDir);
});
it("should create nested directories", async () => {
const nestedDir = path.join(testDir, "nested", "deep");
const result = await ensureDataDir(nestedDir);
expect(result).toBe(nestedDir);
const stats = await fs.stat(nestedDir);
expect(stats.isDirectory()).toBe(true);
});
});
});

View File

@@ -2,6 +2,7 @@ import { describe, it, expect } from "vitest";
import {
isAbortError,
isAuthenticationError,
isCancellationError,
classifyError,
getUserFriendlyErrorMessage,
type ErrorType,
@@ -32,6 +33,34 @@ describe("error-handler.ts", () => {
});
});
describe("isCancellationError", () => {
it("should detect 'cancelled' message", () => {
expect(isCancellationError("Operation was cancelled")).toBe(true);
});
it("should detect 'canceled' message", () => {
expect(isCancellationError("Request was canceled")).toBe(true);
});
it("should detect 'stopped' message", () => {
expect(isCancellationError("Process was stopped")).toBe(true);
});
it("should detect 'aborted' message", () => {
expect(isCancellationError("Task was aborted")).toBe(true);
});
it("should be case insensitive", () => {
expect(isCancellationError("CANCELLED")).toBe(true);
expect(isCancellationError("Canceled")).toBe(true);
});
it("should return false for non-cancellation errors", () => {
expect(isCancellationError("File not found")).toBe(false);
expect(isCancellationError("Network error")).toBe(false);
});
});
describe("isAuthenticationError", () => {
it("should detect 'Authentication failed' message", () => {
expect(isAuthenticationError("Authentication failed")).toBe(true);
@@ -91,6 +120,42 @@ describe("error-handler.ts", () => {
expect(result.isAbort).toBe(true); // Still detected as abort too
});
it("should classify cancellation errors", () => {
const error = new Error("Operation was cancelled");
const result = classifyError(error);
expect(result.type).toBe("cancellation");
expect(result.isCancellation).toBe(true);
expect(result.isAbort).toBe(false);
expect(result.isAuth).toBe(false);
});
it("should prioritize abort over cancellation if both match", () => {
const error = new Error("Operation aborted");
error.name = "AbortError";
const result = classifyError(error);
expect(result.type).toBe("abort");
expect(result.isAbort).toBe(true);
expect(result.isCancellation).toBe(true); // Still detected as cancellation too
});
it("should classify cancellation errors with 'canceled' spelling", () => {
const error = new Error("Request was canceled");
const result = classifyError(error);
expect(result.type).toBe("cancellation");
expect(result.isCancellation).toBe(true);
});
it("should classify cancellation errors with 'stopped' message", () => {
const error = new Error("Process was stopped");
const result = classifyError(error);
expect(result.type).toBe("cancellation");
expect(result.isCancellation).toBe(true);
});
it("should classify generic Error as execution error", () => {
const error = new Error("Something went wrong");
const result = classifyError(error);

View File

@@ -144,6 +144,40 @@ describe("sdk-options.ts", () => {
expect(options.maxTurns).toBe(MAX_TURNS.extended);
expect(options.allowedTools).toEqual([...TOOL_PRESETS.readOnly]);
});
it("should include systemPrompt when provided", async () => {
const { createSuggestionsOptions } = await import("@/lib/sdk-options.js");
const options = createSuggestionsOptions({
cwd: "/test/path",
systemPrompt: "Custom prompt",
});
expect(options.systemPrompt).toBe("Custom prompt");
});
it("should include abortController when provided", async () => {
const { createSuggestionsOptions } = await import("@/lib/sdk-options.js");
const abortController = new AbortController();
const options = createSuggestionsOptions({
cwd: "/test/path",
abortController,
});
expect(options.abortController).toBe(abortController);
});
it("should include outputFormat when provided", async () => {
const { createSuggestionsOptions } = await import("@/lib/sdk-options.js");
const options = createSuggestionsOptions({
cwd: "/test/path",
outputFormat: { type: "json" },
});
expect(options.outputFormat).toEqual({ type: "json" });
});
});
describe("createChatOptions", () => {
@@ -205,6 +239,29 @@ describe("sdk-options.ts", () => {
autoAllowBashIfSandboxed: true,
});
});
it("should include systemPrompt when provided", async () => {
const { createAutoModeOptions } = await import("@/lib/sdk-options.js");
const options = createAutoModeOptions({
cwd: "/test/path",
systemPrompt: "Custom prompt",
});
expect(options.systemPrompt).toBe("Custom prompt");
});
it("should include abortController when provided", async () => {
const { createAutoModeOptions } = await import("@/lib/sdk-options.js");
const abortController = new AbortController();
const options = createAutoModeOptions({
cwd: "/test/path",
abortController,
});
expect(options.abortController).toBe(abortController);
});
});
describe("createCustomOptions", () => {
@@ -234,5 +291,42 @@ describe("sdk-options.ts", () => {
expect(options.maxTurns).toBe(MAX_TURNS.maximum);
expect(options.allowedTools).toEqual([...TOOL_PRESETS.readOnly]);
});
it("should include sandbox when provided", async () => {
const { createCustomOptions } = await import("@/lib/sdk-options.js");
const options = createCustomOptions({
cwd: "/test/path",
sandbox: { enabled: true, autoAllowBashIfSandboxed: false },
});
expect(options.sandbox).toEqual({
enabled: true,
autoAllowBashIfSandboxed: false,
});
});
it("should include systemPrompt when provided", async () => {
const { createCustomOptions } = await import("@/lib/sdk-options.js");
const options = createCustomOptions({
cwd: "/test/path",
systemPrompt: "Custom prompt",
});
expect(options.systemPrompt).toBe("Custom prompt");
});
it("should include abortController when provided", async () => {
const { createCustomOptions } = await import("@/lib/sdk-options.js");
const abortController = new AbortController();
const options = createCustomOptions({
cwd: "/test/path",
abortController,
});
expect(options.abortController).toBe(abortController);
});
});
});

View File

@@ -53,9 +53,24 @@ describe("security.ts", () => {
expect(allowed).toContain(path.resolve("/data/dir"));
});
it("should include WORKSPACE_DIR if set", async () => {
process.env.ALLOWED_PROJECT_DIRS = "";
process.env.DATA_DIR = "";
process.env.WORKSPACE_DIR = "/workspace/dir";
const { initAllowedPaths, getAllowedPaths } = await import(
"@/lib/security.js"
);
initAllowedPaths();
const allowed = getAllowedPaths();
expect(allowed).toContain(path.resolve("/workspace/dir"));
});
it("should handle empty ALLOWED_PROJECT_DIRS", async () => {
process.env.ALLOWED_PROJECT_DIRS = "";
process.env.DATA_DIR = "/data";
delete process.env.WORKSPACE_DIR;
const { initAllowedPaths, getAllowedPaths } = await import(
"@/lib/security.js"
@@ -70,6 +85,7 @@ describe("security.ts", () => {
it("should skip empty entries in comma list", async () => {
process.env.ALLOWED_PROJECT_DIRS = "/path1,,/path2, ,/path3";
process.env.DATA_DIR = "";
delete process.env.WORKSPACE_DIR;
const { initAllowedPaths, getAllowedPaths } = await import(
"@/lib/security.js"

View File

@@ -264,9 +264,66 @@ describe("subprocess-manager.ts", () => {
);
});
// Note: Timeout behavior tests are omitted from unit tests as they involve
// complex timing interactions that are difficult to mock reliably.
// These scenarios are better covered by integration tests with real subprocesses.
// Note: Timeout behavior is difficult to test reliably with mocks due to
// timing interactions. The timeout functionality is covered by integration tests.
// The error handling path (lines 117-118) is tested below.
it("should reset timeout when output is received", async () => {
vi.useFakeTimers();
const mockProcess = createMockProcess({
stdoutLines: [
'{"type":"first"}',
'{"type":"second"}',
'{"type":"third"}',
],
exitCode: 0,
delayMs: 50,
});
vi.mocked(cp.spawn).mockReturnValue(mockProcess);
const generator = spawnJSONLProcess({
...baseOptions,
timeout: 200,
});
const promise = collectAsyncGenerator(generator);
// Advance time but not enough to trigger timeout
await vi.advanceTimersByTimeAsync(150);
// Process should not be killed yet
expect(mockProcess.kill).not.toHaveBeenCalled();
vi.useRealTimers();
await promise;
});
it("should handle errors when reading stdout", async () => {
const mockProcess = new EventEmitter() as any;
const stdout = new Readable({
read() {
// Emit an error after a short delay
setTimeout(() => {
this.emit("error", new Error("Read error"));
}, 10);
},
});
const stderr = new Readable({ read() {} });
mockProcess.stdout = stdout;
mockProcess.stderr = stderr;
mockProcess.kill = vi.fn();
vi.mocked(cp.spawn).mockReturnValue(mockProcess);
const generator = spawnJSONLProcess(baseOptions);
await expect(collectAsyncGenerator(generator)).rejects.toThrow("Read error");
expect(consoleSpy.error).toHaveBeenCalledWith(
expect.stringContaining("Error reading stdout"),
expect.any(Error)
);
});
it("should spawn process with correct arguments", async () => {
const mockProcess = createMockProcess({ exitCode: 0 });

View File

@@ -66,6 +66,32 @@ describe("worktree-metadata.ts", () => {
const result = await readWorktreeMetadata(testProjectPath, branch);
expect(result).toEqual(metadata);
});
it("should handle empty branch name", async () => {
const branch = "";
const metadata: WorktreeMetadata = {
branch: "branch",
createdAt: new Date().toISOString(),
};
// Empty branch name should be sanitized to "_branch"
await writeWorktreeMetadata(testProjectPath, branch, metadata);
const result = await readWorktreeMetadata(testProjectPath, branch);
expect(result).toEqual(metadata);
});
it("should handle branch name that becomes empty after sanitization", async () => {
// Test branch that would become empty after removing invalid chars
const branch = "///";
const metadata: WorktreeMetadata = {
branch: "branch",
createdAt: new Date().toISOString(),
};
await writeWorktreeMetadata(testProjectPath, branch, metadata);
const result = await readWorktreeMetadata(testProjectPath, branch);
expect(result).toEqual(metadata);
});
});
describe("readWorktreeMetadata", () => {

View File

@@ -234,6 +234,30 @@ describe("claude-provider.ts", () => {
}),
});
});
it("should handle errors during execution and rethrow", async () => {
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
const testError = new Error("SDK execution failed");
vi.mocked(sdk.query).mockReturnValue(
(async function* () {
throw testError;
})()
);
const generator = provider.executeQuery({
prompt: "Test",
cwd: "/test",
});
await expect(collectAsyncGenerator(generator)).rejects.toThrow("SDK execution failed");
expect(consoleErrorSpy).toHaveBeenCalledWith(
"[ClaudeProvider] executeQuery() error during execution:",
testError
);
consoleErrorSpy.mockRestore();
});
});
describe("detectInstallation", () => {

View File

@@ -0,0 +1,643 @@
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",
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<Credentials> = {
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<Credentials> = {
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<Credentials> = {
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<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",
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 });
});
});
});