Merge main into massive-terminal-upgrade

Resolves merge conflicts:
- apps/server/src/routes/terminal/common.ts: Keep randomBytes import, use @automaker/utils for createLogger
- apps/ui/eslint.config.mjs: Use main's explicit globals list with XMLHttpRequest and MediaQueryListEvent additions
- apps/ui/src/components/views/terminal-view.tsx: Keep our terminal improvements (killAllSessions, beforeunload, better error handling)
- apps/ui/src/config/terminal-themes.ts: Keep our search highlight colors for all themes
- apps/ui/src/store/app-store.ts: Keep our terminal settings persistence improvements (merge function)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
SuperComboGamer
2025-12-21 20:27:44 -05:00
393 changed files with 32473 additions and 17974 deletions

View File

@@ -1,17 +1,17 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { AgentService } from "@/services/agent-service.js";
import { ProviderFactory } from "@/providers/provider-factory.js";
import * as fs from "fs/promises";
import * as imageHandler from "@/lib/image-handler.js";
import * as promptBuilder from "@/lib/prompt-builder.js";
import { collectAsyncGenerator } from "../../utils/helpers.js";
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { AgentService } from '@/services/agent-service.js';
import { ProviderFactory } from '@/providers/provider-factory.js';
import * as fs from 'fs/promises';
import * as imageHandler from '@automaker/utils';
import * as promptBuilder from '@automaker/utils';
import { collectAsyncGenerator } from '../../utils/helpers.js';
vi.mock("fs/promises");
vi.mock("@/providers/provider-factory.js");
vi.mock("@/lib/image-handler.js");
vi.mock("@/lib/prompt-builder.js");
vi.mock('fs/promises');
vi.mock('@/providers/provider-factory.js');
vi.mock('@automaker/utils');
vi.mock('@automaker/utils');
describe("agent-service.ts", () => {
describe('agent-service.ts', () => {
let service: AgentService;
const mockEvents = {
subscribe: vi.fn(),
@@ -20,86 +20,83 @@ describe("agent-service.ts", () => {
beforeEach(() => {
vi.clearAllMocks();
service = new AgentService("/test/data", mockEvents as any);
service = new AgentService('/test/data', mockEvents as any);
});
describe("initialize", () => {
it("should create state directory", async () => {
describe('initialize', () => {
it('should create state directory', async () => {
vi.mocked(fs.mkdir).mockResolvedValue(undefined);
await service.initialize();
expect(fs.mkdir).toHaveBeenCalledWith(
expect.stringContaining("agent-sessions"),
{ recursive: true }
);
expect(fs.mkdir).toHaveBeenCalledWith(expect.stringContaining('agent-sessions'), {
recursive: true,
});
});
});
describe("startConversation", () => {
it("should create new session with empty messages", async () => {
const error: any = new Error("ENOENT");
error.code = "ENOENT";
describe('startConversation', () => {
it('should create new session with empty messages', async () => {
const error: any = new Error('ENOENT');
error.code = 'ENOENT';
vi.mocked(fs.readFile).mockRejectedValue(error);
const result = await service.startConversation({
sessionId: "session-1",
workingDirectory: "/test/dir",
sessionId: 'session-1',
workingDirectory: '/test/dir',
});
expect(result.success).toBe(true);
expect(result.messages).toEqual([]);
expect(result.sessionId).toBe("session-1");
expect(result.sessionId).toBe('session-1');
});
it("should load existing session", async () => {
it('should load existing session', async () => {
const existingMessages = [
{
id: "msg-1",
role: "user",
content: "Hello",
timestamp: "2024-01-01T00:00:00Z",
id: 'msg-1',
role: 'user',
content: 'Hello',
timestamp: '2024-01-01T00:00:00Z',
},
];
vi.mocked(fs.readFile).mockResolvedValue(
JSON.stringify(existingMessages)
);
vi.mocked(fs.readFile).mockResolvedValue(JSON.stringify(existingMessages));
const result = await service.startConversation({
sessionId: "session-1",
workingDirectory: "/test/dir",
sessionId: 'session-1',
workingDirectory: '/test/dir',
});
expect(result.success).toBe(true);
expect(result.messages).toEqual(existingMessages);
});
it("should use process.cwd() if no working directory provided", async () => {
const error: any = new Error("ENOENT");
error.code = "ENOENT";
it('should use process.cwd() if no working directory provided', async () => {
const error: any = new Error('ENOENT');
error.code = 'ENOENT';
vi.mocked(fs.readFile).mockRejectedValue(error);
const result = await service.startConversation({
sessionId: "session-1",
sessionId: 'session-1',
});
expect(result.success).toBe(true);
});
it("should reuse existing session if already started", async () => {
const error: any = new Error("ENOENT");
error.code = "ENOENT";
it('should reuse existing session if already started', async () => {
const error: any = new Error('ENOENT');
error.code = 'ENOENT';
vi.mocked(fs.readFile).mockRejectedValue(error);
// Start session first time
await service.startConversation({
sessionId: "session-1",
sessionId: 'session-1',
});
// Start again with same ID
const result = await service.startConversation({
sessionId: "session-1",
sessionId: 'session-1',
});
expect(result.success).toBe(true);
@@ -109,252 +106,237 @@ describe("agent-service.ts", () => {
});
});
describe("sendMessage", () => {
describe('sendMessage', () => {
beforeEach(async () => {
const error: any = new Error("ENOENT");
error.code = "ENOENT";
const error: any = new Error('ENOENT');
error.code = 'ENOENT';
vi.mocked(fs.readFile).mockRejectedValue(error);
vi.mocked(fs.writeFile).mockResolvedValue(undefined);
vi.mocked(fs.mkdir).mockResolvedValue(undefined);
await service.startConversation({
sessionId: "session-1",
workingDirectory: "/test/dir",
sessionId: 'session-1',
workingDirectory: '/test/dir',
});
});
it("should throw if session not found", async () => {
it('should throw if session not found', async () => {
await expect(
service.sendMessage({
sessionId: "nonexistent",
message: "Hello",
sessionId: 'nonexistent',
message: 'Hello',
})
).rejects.toThrow("Session nonexistent not found");
).rejects.toThrow('Session nonexistent not found');
});
it("should process message and stream responses", async () => {
it('should process message and stream responses', async () => {
const mockProvider = {
getName: () => "claude",
getName: () => 'claude',
executeQuery: async function* () {
yield {
type: "assistant",
type: 'assistant',
message: {
role: "assistant",
content: [{ type: "text", text: "Response" }],
role: 'assistant',
content: [{ type: 'text', text: 'Response' }],
},
};
yield {
type: "result",
subtype: "success",
type: 'result',
subtype: 'success',
};
},
};
vi.mocked(ProviderFactory.getProviderForModel).mockReturnValue(
mockProvider as any
);
vi.mocked(ProviderFactory.getProviderForModel).mockReturnValue(mockProvider as any);
vi.mocked(promptBuilder.buildPromptWithImages).mockResolvedValue({
content: "Hello",
content: 'Hello',
hasImages: false,
});
const result = await service.sendMessage({
sessionId: "session-1",
message: "Hello",
workingDirectory: "/custom/dir",
sessionId: 'session-1',
message: 'Hello',
workingDirectory: '/custom/dir',
});
expect(result.success).toBe(true);
expect(mockEvents.emit).toHaveBeenCalled();
});
it("should handle images in message", async () => {
it('should handle images in message', async () => {
const mockProvider = {
getName: () => "claude",
getName: () => 'claude',
executeQuery: async function* () {
yield {
type: "result",
subtype: "success",
type: 'result',
subtype: 'success',
};
},
};
vi.mocked(ProviderFactory.getProviderForModel).mockReturnValue(
mockProvider as any
);
vi.mocked(ProviderFactory.getProviderForModel).mockReturnValue(mockProvider as any);
vi.mocked(imageHandler.readImageAsBase64).mockResolvedValue({
base64: "base64data",
mimeType: "image/png",
filename: "test.png",
originalPath: "/path/test.png",
base64: 'base64data',
mimeType: 'image/png',
filename: 'test.png',
originalPath: '/path/test.png',
});
vi.mocked(promptBuilder.buildPromptWithImages).mockResolvedValue({
content: "Check image",
content: 'Check image',
hasImages: true,
});
await service.sendMessage({
sessionId: "session-1",
message: "Check this",
imagePaths: ["/path/test.png"],
sessionId: 'session-1',
message: 'Check this',
imagePaths: ['/path/test.png'],
});
expect(imageHandler.readImageAsBase64).toHaveBeenCalledWith(
"/path/test.png"
);
expect(imageHandler.readImageAsBase64).toHaveBeenCalledWith('/path/test.png');
});
it("should handle failed image loading gracefully", async () => {
it('should handle failed image loading gracefully', async () => {
const mockProvider = {
getName: () => "claude",
getName: () => 'claude',
executeQuery: async function* () {
yield {
type: "result",
subtype: "success",
type: 'result',
subtype: 'success',
};
},
};
vi.mocked(ProviderFactory.getProviderForModel).mockReturnValue(
mockProvider as any
);
vi.mocked(ProviderFactory.getProviderForModel).mockReturnValue(mockProvider as any);
vi.mocked(imageHandler.readImageAsBase64).mockRejectedValue(
new Error("Image not found")
);
vi.mocked(imageHandler.readImageAsBase64).mockRejectedValue(new Error('Image not found'));
vi.mocked(promptBuilder.buildPromptWithImages).mockResolvedValue({
content: "Check image",
content: 'Check image',
hasImages: false,
});
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
await service.sendMessage({
sessionId: "session-1",
message: "Check this",
imagePaths: ["/path/test.png"],
sessionId: 'session-1',
message: 'Check this',
imagePaths: ['/path/test.png'],
});
expect(consoleSpy).toHaveBeenCalled();
consoleSpy.mockRestore();
});
it("should use custom model if provided", async () => {
it('should use custom model if provided', async () => {
const mockProvider = {
getName: () => "claude",
getName: () => 'claude',
executeQuery: async function* () {
yield {
type: "result",
subtype: "success",
type: 'result',
subtype: 'success',
};
},
};
vi.mocked(ProviderFactory.getProviderForModel).mockReturnValue(
mockProvider as any
);
vi.mocked(ProviderFactory.getProviderForModel).mockReturnValue(mockProvider as any);
vi.mocked(promptBuilder.buildPromptWithImages).mockResolvedValue({
content: "Hello",
content: 'Hello',
hasImages: false,
});
await service.sendMessage({
sessionId: "session-1",
message: "Hello",
model: "claude-sonnet-4-20250514",
sessionId: 'session-1',
message: 'Hello',
model: 'claude-sonnet-4-20250514',
});
expect(ProviderFactory.getProviderForModel).toHaveBeenCalledWith("claude-sonnet-4-20250514");
expect(ProviderFactory.getProviderForModel).toHaveBeenCalledWith('claude-sonnet-4-20250514');
});
it("should save session messages", async () => {
it('should save session messages', async () => {
const mockProvider = {
getName: () => "claude",
getName: () => 'claude',
executeQuery: async function* () {
yield {
type: "result",
subtype: "success",
type: 'result',
subtype: 'success',
};
},
};
vi.mocked(ProviderFactory.getProviderForModel).mockReturnValue(
mockProvider as any
);
vi.mocked(ProviderFactory.getProviderForModel).mockReturnValue(mockProvider as any);
vi.mocked(promptBuilder.buildPromptWithImages).mockResolvedValue({
content: "Hello",
content: 'Hello',
hasImages: false,
});
await service.sendMessage({
sessionId: "session-1",
message: "Hello",
sessionId: 'session-1',
message: 'Hello',
});
expect(fs.writeFile).toHaveBeenCalled();
});
});
describe("stopExecution", () => {
it("should stop execution for a session", async () => {
const error: any = new Error("ENOENT");
error.code = "ENOENT";
describe('stopExecution', () => {
it('should stop execution for a session', async () => {
const error: any = new Error('ENOENT');
error.code = 'ENOENT';
vi.mocked(fs.readFile).mockRejectedValue(error);
await service.startConversation({
sessionId: "session-1",
sessionId: 'session-1',
});
// Should return success
const result = await service.stopExecution("session-1");
const result = await service.stopExecution('session-1');
expect(result.success).toBeDefined();
});
});
describe("getHistory", () => {
it("should return message history", async () => {
const error: any = new Error("ENOENT");
error.code = "ENOENT";
describe('getHistory', () => {
it('should return message history', async () => {
const error: any = new Error('ENOENT');
error.code = 'ENOENT';
vi.mocked(fs.readFile).mockRejectedValue(error);
await service.startConversation({
sessionId: "session-1",
sessionId: 'session-1',
});
const history = service.getHistory("session-1");
const history = service.getHistory('session-1');
expect(history).toBeDefined();
expect(history?.messages).toEqual([]);
});
it("should handle non-existent session", () => {
const history = service.getHistory("nonexistent");
it('should handle non-existent session', () => {
const history = service.getHistory('nonexistent');
expect(history).toBeDefined(); // Returns error object
});
});
describe("clearSession", () => {
it("should clear session messages", async () => {
const error: any = new Error("ENOENT");
error.code = "ENOENT";
describe('clearSession', () => {
it('should clear session messages', async () => {
const error: any = new Error('ENOENT');
error.code = 'ENOENT';
vi.mocked(fs.readFile).mockRejectedValue(error);
vi.mocked(fs.writeFile).mockResolvedValue(undefined);
vi.mocked(fs.mkdir).mockResolvedValue(undefined);
await service.startConversation({
sessionId: "session-1",
sessionId: 'session-1',
});
await service.clearSession("session-1");
await service.clearSession('session-1');
const history = service.getHistory("session-1");
const history = service.getHistory('session-1');
expect(history?.messages).toEqual([]);
expect(fs.writeFile).toHaveBeenCalled();
});

View File

@@ -0,0 +1,644 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { ClaudeUsageService } from '@/services/claude-usage-service.js';
import { spawn } from 'child_process';
import * as pty from 'node-pty';
import * as os from 'os';
vi.mock('child_process');
vi.mock('node-pty');
vi.mock('os');
describe('claude-usage-service.ts', () => {
let service: ClaudeUsageService;
let mockSpawnProcess: any;
let mockPtyProcess: any;
beforeEach(() => {
vi.clearAllMocks();
service = new ClaudeUsageService();
// Mock spawn process for isAvailable and Mac commands
mockSpawnProcess = {
on: vi.fn(),
kill: vi.fn(),
stdout: {
on: vi.fn(),
},
stderr: {
on: vi.fn(),
},
};
// Mock PTY process for Windows
mockPtyProcess = {
onData: vi.fn(),
onExit: vi.fn(),
write: vi.fn(),
kill: vi.fn(),
};
vi.mocked(spawn).mockReturnValue(mockSpawnProcess as any);
vi.mocked(pty.spawn).mockReturnValue(mockPtyProcess);
});
describe('isAvailable', () => {
it('should return true when Claude CLI is available', async () => {
vi.mocked(os.platform).mockReturnValue('darwin');
// Simulate successful which/where command
mockSpawnProcess.on.mockImplementation((event: string, callback: Function) => {
if (event === 'close') {
callback(0); // Exit code 0 = found
}
return mockSpawnProcess;
});
const result = await service.isAvailable();
expect(result).toBe(true);
expect(spawn).toHaveBeenCalledWith('which', ['claude']);
});
it('should return false when Claude CLI is not available', async () => {
vi.mocked(os.platform).mockReturnValue('darwin');
mockSpawnProcess.on.mockImplementation((event: string, callback: Function) => {
if (event === 'close') {
callback(1); // Exit code 1 = not found
}
return mockSpawnProcess;
});
const result = await service.isAvailable();
expect(result).toBe(false);
});
it('should return false on error', async () => {
vi.mocked(os.platform).mockReturnValue('darwin');
mockSpawnProcess.on.mockImplementation((event: string, callback: Function) => {
if (event === 'error') {
callback(new Error('Command failed'));
}
return mockSpawnProcess;
});
const result = await service.isAvailable();
expect(result).toBe(false);
});
it("should use 'where' command on Windows", async () => {
vi.mocked(os.platform).mockReturnValue('win32');
const windowsService = new ClaudeUsageService(); // Create new service after platform mock
mockSpawnProcess.on.mockImplementation((event: string, callback: Function) => {
if (event === 'close') {
callback(0);
}
return mockSpawnProcess;
});
await windowsService.isAvailable();
expect(spawn).toHaveBeenCalledWith('where', ['claude']);
});
});
describe('stripAnsiCodes', () => {
it('should strip ANSI color codes from text', () => {
const service = new ClaudeUsageService();
const input = '\x1B[31mRed text\x1B[0m Normal text';
// @ts-expect-error - accessing private method for testing
const result = service.stripAnsiCodes(input);
expect(result).toBe('Red text Normal text');
});
it('should handle text without ANSI codes', () => {
const service = new ClaudeUsageService();
const input = 'Plain text';
// @ts-expect-error - accessing private method for testing
const result = service.stripAnsiCodes(input);
expect(result).toBe('Plain text');
});
});
describe('parseResetTime', () => {
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2025-01-15T10:00:00Z'));
});
afterEach(() => {
vi.useRealTimers();
});
it('should parse duration format with hours and minutes', () => {
const service = new ClaudeUsageService();
const text = 'Resets in 2h 15m';
// @ts-expect-error - accessing private method for testing
const result = service.parseResetTime(text, 'session');
const expected = new Date('2025-01-15T12:15:00Z');
expect(new Date(result)).toEqual(expected);
});
it('should parse duration format with only minutes', () => {
const service = new ClaudeUsageService();
const text = 'Resets in 30m';
// @ts-expect-error - accessing private method for testing
const result = service.parseResetTime(text, 'session');
const expected = new Date('2025-01-15T10:30:00Z');
expect(new Date(result)).toEqual(expected);
});
it('should parse simple time format (AM)', () => {
const service = new ClaudeUsageService();
const text = 'Resets 11am';
// @ts-expect-error - accessing private method for testing
const result = service.parseResetTime(text, 'session');
// Should be today at 11am, or tomorrow if already passed
const resultDate = new Date(result);
expect(resultDate.getHours()).toBe(11);
expect(resultDate.getMinutes()).toBe(0);
});
it('should parse simple time format (PM)', () => {
const service = new ClaudeUsageService();
const text = 'Resets 3pm';
// @ts-expect-error - accessing private method for testing
const result = service.parseResetTime(text, 'session');
const resultDate = new Date(result);
expect(resultDate.getHours()).toBe(15);
expect(resultDate.getMinutes()).toBe(0);
});
it('should parse date format with month, day, and time', () => {
const service = new ClaudeUsageService();
const text = 'Resets Dec 22 at 8pm';
// @ts-expect-error - accessing private method for testing
const result = service.parseResetTime(text, 'weekly');
const resultDate = new Date(result);
expect(resultDate.getMonth()).toBe(11); // December = 11
expect(resultDate.getDate()).toBe(22);
expect(resultDate.getHours()).toBe(20);
});
it('should parse date format with comma separator', () => {
const service = new ClaudeUsageService();
const text = 'Resets Jan 15, 3:30pm';
// @ts-expect-error - accessing private method for testing
const result = service.parseResetTime(text, 'weekly');
const resultDate = new Date(result);
expect(resultDate.getMonth()).toBe(0); // January = 0
expect(resultDate.getDate()).toBe(15);
expect(resultDate.getHours()).toBe(15);
expect(resultDate.getMinutes()).toBe(30);
});
it('should handle 12am correctly', () => {
const service = new ClaudeUsageService();
const text = 'Resets 12am';
// @ts-expect-error - accessing private method for testing
const result = service.parseResetTime(text, 'session');
const resultDate = new Date(result);
expect(resultDate.getHours()).toBe(0);
});
it('should handle 12pm correctly', () => {
const service = new ClaudeUsageService();
const text = 'Resets 12pm';
// @ts-expect-error - accessing private method for testing
const result = service.parseResetTime(text, 'session');
const resultDate = new Date(result);
expect(resultDate.getHours()).toBe(12);
});
it('should return default reset time for unparseable text', () => {
const service = new ClaudeUsageService();
const text = 'Invalid reset text';
// @ts-expect-error - accessing private method for testing
const result = service.parseResetTime(text, 'session');
// @ts-expect-error - accessing private method for testing
const defaultResult = service.getDefaultResetTime('session');
expect(result).toBe(defaultResult);
});
});
describe('getDefaultResetTime', () => {
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2025-01-15T10:00:00Z')); // Wednesday
});
afterEach(() => {
vi.useRealTimers();
});
it('should return session default (5 hours from now)', () => {
const service = new ClaudeUsageService();
// @ts-expect-error - accessing private method for testing
const result = service.getDefaultResetTime('session');
const expected = new Date('2025-01-15T15:00:00Z');
expect(new Date(result)).toEqual(expected);
});
it('should return weekly default (next Monday at noon)', () => {
const service = new ClaudeUsageService();
// @ts-expect-error - accessing private method for testing
const result = service.getDefaultResetTime('weekly');
const resultDate = new Date(result);
// Next Monday from Wednesday should be 5 days away
expect(resultDate.getDay()).toBe(1); // Monday
expect(resultDate.getHours()).toBe(12);
expect(resultDate.getMinutes()).toBe(59);
});
});
describe('parseSection', () => {
it('should parse section with percentage left', () => {
const service = new ClaudeUsageService();
const lines = ['Current session', '████████████████░░░░ 65% left', 'Resets in 2h 15m'];
// @ts-expect-error - accessing private method for testing
const result = service.parseSection(lines, 'Current session', 'session');
expect(result.percentage).toBe(35); // 100 - 65 = 35% used
expect(result.resetText).toBe('Resets in 2h 15m');
});
it('should parse section with percentage used', () => {
const service = new ClaudeUsageService();
const lines = [
'Current week (all models)',
'██████████░░░░░░░░░░ 40% used',
'Resets Jan 15, 3:30pm',
];
// @ts-expect-error - accessing private method for testing
const result = service.parseSection(lines, 'Current week (all models)', 'weekly');
expect(result.percentage).toBe(40); // Already in % used
});
it('should return zero percentage when section not found', () => {
const service = new ClaudeUsageService();
const lines = ['Some other text', 'No matching section'];
// @ts-expect-error - accessing private method for testing
const result = service.parseSection(lines, 'Current session', 'session');
expect(result.percentage).toBe(0);
});
it('should strip timezone from reset text', () => {
const service = new ClaudeUsageService();
const lines = ['Current session', '65% left', 'Resets 3pm (America/Los_Angeles)'];
// @ts-expect-error - accessing private method for testing
const result = service.parseSection(lines, 'Current session', 'session');
expect(result.resetText).toBe('Resets 3pm');
expect(result.resetText).not.toContain('America/Los_Angeles');
});
it('should handle case-insensitive section matching', () => {
const service = new ClaudeUsageService();
const lines = ['CURRENT SESSION', '65% left', 'Resets in 2h'];
// @ts-expect-error - accessing private method for testing
const result = service.parseSection(lines, 'current session', 'session');
expect(result.percentage).toBe(35);
});
});
describe('parseUsageOutput', () => {
it('should parse complete usage output', () => {
const service = new ClaudeUsageService();
const output = `
Claude Code v1.0.27
Current session
████████████████░░░░ 65% left
Resets in 2h 15m
Current week (all models)
██████████░░░░░░░░░░ 35% left
Resets Jan 15, 3:30pm (America/Los_Angeles)
Current week (Sonnet only)
████████████████████ 80% left
Resets Jan 15, 3:30pm (America/Los_Angeles)
`;
// @ts-expect-error - accessing private method for testing
const result = service.parseUsageOutput(output);
expect(result.sessionPercentage).toBe(35); // 100 - 65
expect(result.weeklyPercentage).toBe(65); // 100 - 35
expect(result.sonnetWeeklyPercentage).toBe(20); // 100 - 80
expect(result.sessionResetText).toContain('Resets in 2h 15m');
expect(result.weeklyResetText).toContain('Resets Jan 15, 3:30pm');
expect(result.userTimezone).toBe(Intl.DateTimeFormat().resolvedOptions().timeZone);
});
it('should handle output with ANSI codes', () => {
const service = new ClaudeUsageService();
const output = `
\x1B[1mClaude Code v1.0.27\x1B[0m
\x1B[1mCurrent session\x1B[0m
\x1B[32m████████████████░░░░\x1B[0m 65% left
Resets in 2h 15m
`;
// @ts-expect-error - accessing private method for testing
const result = service.parseUsageOutput(output);
expect(result.sessionPercentage).toBe(35);
});
it('should handle Opus section name', () => {
const service = new ClaudeUsageService();
const output = `
Current session
65% left
Resets in 2h
Current week (all models)
35% left
Resets Jan 15, 3pm
Current week (Opus)
90% left
Resets Jan 15, 3pm
`;
// @ts-expect-error - accessing private method for testing
const result = service.parseUsageOutput(output);
expect(result.sonnetWeeklyPercentage).toBe(10); // 100 - 90
});
it('should set default values for missing sections', () => {
const service = new ClaudeUsageService();
const output = 'Claude Code v1.0.27';
// @ts-expect-error - accessing private method for testing
const result = service.parseUsageOutput(output);
expect(result.sessionPercentage).toBe(0);
expect(result.weeklyPercentage).toBe(0);
expect(result.sonnetWeeklyPercentage).toBe(0);
expect(result.sessionTokensUsed).toBe(0);
expect(result.sessionLimit).toBe(0);
expect(result.costUsed).toBeNull();
expect(result.costLimit).toBeNull();
expect(result.costCurrency).toBeNull();
});
});
describe('executeClaudeUsageCommandMac', () => {
beforeEach(() => {
vi.mocked(os.platform).mockReturnValue('darwin');
vi.spyOn(process, 'env', 'get').mockReturnValue({ HOME: '/Users/testuser' });
});
it('should execute expect script and return output', async () => {
const mockOutput = `
Current session
65% left
Resets in 2h
`;
let stdoutCallback: Function;
let closeCallback: Function;
mockSpawnProcess.stdout = {
on: vi.fn((event: string, callback: Function) => {
if (event === 'data') {
stdoutCallback = callback;
}
}),
};
mockSpawnProcess.stderr = {
on: vi.fn(),
};
mockSpawnProcess.on = vi.fn((event: string, callback: Function) => {
if (event === 'close') {
closeCallback = callback;
}
return mockSpawnProcess;
});
const promise = service.fetchUsageData();
// Simulate stdout data
stdoutCallback!(Buffer.from(mockOutput));
// Simulate successful close
closeCallback!(0);
const result = await promise;
expect(result.sessionPercentage).toBe(35); // 100 - 65
expect(spawn).toHaveBeenCalledWith(
'expect',
expect.arrayContaining(['-c']),
expect.any(Object)
);
});
it('should handle authentication errors', async () => {
const mockOutput = 'token_expired';
let stdoutCallback: Function;
let closeCallback: Function;
mockSpawnProcess.stdout = {
on: vi.fn((event: string, callback: Function) => {
if (event === 'data') {
stdoutCallback = callback;
}
}),
};
mockSpawnProcess.stderr = {
on: vi.fn(),
};
mockSpawnProcess.on = vi.fn((event: string, callback: Function) => {
if (event === 'close') {
closeCallback = callback;
}
return mockSpawnProcess;
});
const promise = service.fetchUsageData();
stdoutCallback!(Buffer.from(mockOutput));
closeCallback!(1);
await expect(promise).rejects.toThrow('Authentication required');
});
it('should handle timeout', async () => {
vi.useFakeTimers();
mockSpawnProcess.stdout = {
on: vi.fn(),
};
mockSpawnProcess.stderr = {
on: vi.fn(),
};
mockSpawnProcess.on = vi.fn(() => mockSpawnProcess);
mockSpawnProcess.kill = vi.fn();
const promise = service.fetchUsageData();
// Advance time past timeout (30 seconds)
vi.advanceTimersByTime(31000);
await expect(promise).rejects.toThrow('Command timed out');
vi.useRealTimers();
});
});
describe('executeClaudeUsageCommandWindows', () => {
beforeEach(() => {
vi.mocked(os.platform).mockReturnValue('win32');
vi.mocked(os.homedir).mockReturnValue('C:\\Users\\testuser');
vi.spyOn(process, 'env', 'get').mockReturnValue({ USERPROFILE: 'C:\\Users\\testuser' });
});
it('should use node-pty on Windows and return output', async () => {
const windowsService = new ClaudeUsageService(); // Create new service for Windows platform
const mockOutput = `
Current session
65% left
Resets in 2h
`;
let dataCallback: Function | undefined;
let exitCallback: Function | undefined;
const mockPty = {
onData: vi.fn((callback: Function) => {
dataCallback = callback;
}),
onExit: vi.fn((callback: Function) => {
exitCallback = callback;
}),
write: vi.fn(),
kill: vi.fn(),
};
vi.mocked(pty.spawn).mockReturnValue(mockPty as any);
const promise = windowsService.fetchUsageData();
// Simulate data
dataCallback!(mockOutput);
// Simulate successful exit
exitCallback!({ exitCode: 0 });
const result = await promise;
expect(result.sessionPercentage).toBe(35);
expect(pty.spawn).toHaveBeenCalledWith(
'cmd.exe',
['/c', 'claude', '/usage'],
expect.any(Object)
);
});
it('should send escape key after seeing usage data', async () => {
vi.useFakeTimers();
const windowsService = new ClaudeUsageService();
const mockOutput = 'Current session\n65% left';
let dataCallback: Function | undefined;
let exitCallback: Function | undefined;
const mockPty = {
onData: vi.fn((callback: Function) => {
dataCallback = callback;
}),
onExit: vi.fn((callback: Function) => {
exitCallback = callback;
}),
write: vi.fn(),
kill: vi.fn(),
};
vi.mocked(pty.spawn).mockReturnValue(mockPty as any);
const promise = windowsService.fetchUsageData();
// Simulate seeing usage data
dataCallback!(mockOutput);
// Advance time to trigger escape key sending
vi.advanceTimersByTime(2100);
expect(mockPty.write).toHaveBeenCalledWith('\x1b');
// Complete the promise to avoid unhandled rejection
exitCallback!({ exitCode: 0 });
await promise;
vi.useRealTimers();
});
it('should handle authentication errors on Windows', async () => {
const windowsService = new ClaudeUsageService();
let dataCallback: Function | undefined;
let exitCallback: Function | undefined;
const mockPty = {
onData: vi.fn((callback: Function) => {
dataCallback = callback;
}),
onExit: vi.fn((callback: Function) => {
exitCallback = callback;
}),
write: vi.fn(),
kill: vi.fn(),
};
vi.mocked(pty.spawn).mockReturnValue(mockPty as any);
const promise = windowsService.fetchUsageData();
dataCallback!('authentication_error');
exitCallback!({ exitCode: 1 });
await expect(promise).rejects.toThrow('Authentication required');
});
it('should handle timeout on Windows', async () => {
vi.useFakeTimers();
const windowsService = new ClaudeUsageService();
const mockPty = {
onData: vi.fn(),
onExit: vi.fn(),
write: vi.fn(),
kill: vi.fn(),
};
vi.mocked(pty.spawn).mockReturnValue(mockPty as any);
const promise = windowsService.fetchUsageData();
vi.advanceTimersByTime(31000);
await expect(promise).rejects.toThrow('Command timed out');
expect(mockPty.kill).toHaveBeenCalled();
vi.useRealTimers();
});
});
});

View File

@@ -1,37 +1,33 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { EventEmitter } from "events";
import path from "path";
import os from "os";
import fs from "fs/promises";
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { EventEmitter } from 'events';
import path from 'path';
import os from 'os';
import fs from 'fs/promises';
// Mock child_process
vi.mock("child_process", () => ({
vi.mock('child_process', () => ({
spawn: vi.fn(),
execSync: vi.fn(),
}));
// Mock fs existsSync
vi.mock("fs", async (importOriginal) => {
const actual = await importOriginal<typeof import("fs")>();
return {
...actual,
existsSync: vi.fn(),
};
});
// Mock secure-fs
vi.mock('@/lib/secure-fs.js', () => ({
access: vi.fn(),
}));
// Mock net
vi.mock("net", () => ({
vi.mock('net', () => ({
default: {
createServer: vi.fn(),
},
createServer: vi.fn(),
}));
import { spawn, execSync } from "child_process";
import { existsSync } from "fs";
import net from "net";
import { spawn, execSync } from 'child_process';
import * as secureFs from '@/lib/secure-fs.js';
import net from 'net';
describe("dev-server-service.ts", () => {
describe('dev-server-service.ts', () => {
let testDir: string;
beforeEach(async () => {
@@ -41,20 +37,20 @@ describe("dev-server-service.ts", () => {
testDir = path.join(os.tmpdir(), `dev-server-test-${Date.now()}`);
await fs.mkdir(testDir, { recursive: true });
// Default mock for existsSync - return true
vi.mocked(existsSync).mockReturnValue(true);
// Default mock for secureFs.access - return resolved (file exists)
vi.mocked(secureFs.access).mockResolvedValue(undefined);
// Default mock for net.createServer - port available
const mockServer = new EventEmitter() as any;
mockServer.listen = vi.fn().mockImplementation((port: number, host: string) => {
process.nextTick(() => mockServer.emit("listening"));
process.nextTick(() => mockServer.emit('listening'));
});
mockServer.close = vi.fn();
vi.mocked(net.createServer).mockReturnValue(mockServer);
// Default mock for execSync - no process on port
vi.mocked(execSync).mockImplementation(() => {
throw new Error("No process found");
throw new Error('No process found');
});
});
@@ -66,11 +62,9 @@ describe("dev-server-service.ts", () => {
}
});
describe("getDevServerService", () => {
it("should return a singleton instance", async () => {
const { getDevServerService } = await import(
"@/services/dev-server-service.js"
);
describe('getDevServerService', () => {
it('should return a singleton instance', async () => {
const { getDevServerService } = await import('@/services/dev-server-service.js');
const instance1 = getDevServerService();
const instance2 = getDevServerService();
@@ -79,148 +73,125 @@ describe("dev-server-service.ts", () => {
});
});
describe("startDevServer", () => {
it("should return error if worktree path does not exist", async () => {
vi.mocked(existsSync).mockReturnValue(false);
describe('startDevServer', () => {
it('should return error if worktree path does not exist', async () => {
vi.mocked(secureFs.access).mockRejectedValueOnce(new Error('File not found'));
const { getDevServerService } = await import(
"@/services/dev-server-service.js"
);
const { getDevServerService } = await import('@/services/dev-server-service.js');
const service = getDevServerService();
const result = await service.startDevServer(
"/project",
"/nonexistent/worktree"
);
const result = await service.startDevServer('/project', '/nonexistent/worktree');
expect(result.success).toBe(false);
expect(result.error).toContain("does not exist");
expect(result.error).toContain('does not exist');
});
it("should return error if no package.json found", async () => {
vi.mocked(existsSync).mockImplementation((p: any) => {
if (p.includes("package.json")) return false;
return true;
it('should return error if no package.json found', async () => {
vi.mocked(secureFs.access).mockImplementation(async (p: any) => {
if (typeof p === 'string' && p.includes('package.json')) {
throw new Error('File not found');
}
return undefined;
});
const { getDevServerService } = await import(
"@/services/dev-server-service.js"
);
const { getDevServerService } = await import('@/services/dev-server-service.js');
const service = getDevServerService();
const result = await service.startDevServer(testDir, testDir);
expect(result.success).toBe(false);
expect(result.error).toContain("No package.json found");
expect(result.error).toContain('No package.json found');
});
it("should detect npm as package manager with package-lock.json", async () => {
vi.mocked(existsSync).mockImplementation((p: any) => {
if (p.includes("bun.lockb")) return false;
if (p.includes("pnpm-lock.yaml")) return false;
if (p.includes("yarn.lock")) return false;
if (p.includes("package-lock.json")) return true;
if (p.includes("package.json")) return true;
return true;
it('should detect npm as package manager with package-lock.json', async () => {
vi.mocked(secureFs.access).mockImplementation(async (p: any) => {
const pathStr = typeof p === 'string' ? p : '';
if (pathStr.includes('bun.lockb')) throw new Error('Not found');
if (pathStr.includes('pnpm-lock.yaml')) throw new Error('Not found');
if (pathStr.includes('yarn.lock')) throw new Error('Not found');
if (pathStr.includes('package-lock.json')) return undefined;
if (pathStr.includes('package.json')) return undefined;
return undefined;
});
const mockProcess = createMockProcess();
vi.mocked(spawn).mockReturnValue(mockProcess as any);
const { getDevServerService } = await import(
"@/services/dev-server-service.js"
);
const { getDevServerService } = await import('@/services/dev-server-service.js');
const service = getDevServerService();
await service.startDevServer(testDir, testDir);
expect(spawn).toHaveBeenCalledWith(
"npm",
["run", "dev"],
expect.any(Object)
);
expect(spawn).toHaveBeenCalledWith('npm', ['run', 'dev'], expect.any(Object));
});
it("should detect yarn as package manager with yarn.lock", async () => {
vi.mocked(existsSync).mockImplementation((p: any) => {
if (p.includes("bun.lockb")) return false;
if (p.includes("pnpm-lock.yaml")) return false;
if (p.includes("yarn.lock")) return true;
if (p.includes("package.json")) return true;
return true;
it('should detect yarn as package manager with yarn.lock', async () => {
vi.mocked(secureFs.access).mockImplementation(async (p: any) => {
const pathStr = typeof p === 'string' ? p : '';
if (pathStr.includes('bun.lockb')) throw new Error('Not found');
if (pathStr.includes('pnpm-lock.yaml')) throw new Error('Not found');
if (pathStr.includes('yarn.lock')) return undefined;
if (pathStr.includes('package.json')) return undefined;
return undefined;
});
const mockProcess = createMockProcess();
vi.mocked(spawn).mockReturnValue(mockProcess as any);
const { getDevServerService } = await import(
"@/services/dev-server-service.js"
);
const { getDevServerService } = await import('@/services/dev-server-service.js');
const service = getDevServerService();
await service.startDevServer(testDir, testDir);
expect(spawn).toHaveBeenCalledWith("yarn", ["dev"], expect.any(Object));
expect(spawn).toHaveBeenCalledWith('yarn', ['dev'], expect.any(Object));
});
it("should detect pnpm as package manager with pnpm-lock.yaml", async () => {
vi.mocked(existsSync).mockImplementation((p: any) => {
if (p.includes("bun.lockb")) return false;
if (p.includes("pnpm-lock.yaml")) return true;
if (p.includes("package.json")) return true;
return true;
it('should detect pnpm as package manager with pnpm-lock.yaml', async () => {
vi.mocked(secureFs.access).mockImplementation(async (p: any) => {
const pathStr = typeof p === 'string' ? p : '';
if (pathStr.includes('bun.lockb')) throw new Error('Not found');
if (pathStr.includes('pnpm-lock.yaml')) return undefined;
if (pathStr.includes('package.json')) return undefined;
return undefined;
});
const mockProcess = createMockProcess();
vi.mocked(spawn).mockReturnValue(mockProcess as any);
const { getDevServerService } = await import(
"@/services/dev-server-service.js"
);
const { getDevServerService } = await import('@/services/dev-server-service.js');
const service = getDevServerService();
await service.startDevServer(testDir, testDir);
expect(spawn).toHaveBeenCalledWith(
"pnpm",
["run", "dev"],
expect.any(Object)
);
expect(spawn).toHaveBeenCalledWith('pnpm', ['run', 'dev'], expect.any(Object));
});
it("should detect bun as package manager with bun.lockb", async () => {
vi.mocked(existsSync).mockImplementation((p: any) => {
if (p.includes("bun.lockb")) return true;
if (p.includes("package.json")) return true;
return true;
it('should detect bun as package manager with bun.lockb', async () => {
vi.mocked(secureFs.access).mockImplementation(async (p: any) => {
const pathStr = typeof p === 'string' ? p : '';
if (pathStr.includes('bun.lockb')) return undefined;
if (pathStr.includes('package.json')) return undefined;
return undefined;
});
const mockProcess = createMockProcess();
vi.mocked(spawn).mockReturnValue(mockProcess as any);
const { getDevServerService } = await import(
"@/services/dev-server-service.js"
);
const { getDevServerService } = await import('@/services/dev-server-service.js');
const service = getDevServerService();
await service.startDevServer(testDir, testDir);
expect(spawn).toHaveBeenCalledWith(
"bun",
["run", "dev"],
expect.any(Object)
);
expect(spawn).toHaveBeenCalledWith('bun', ['run', 'dev'], expect.any(Object));
});
it("should return existing server info if already running", async () => {
vi.mocked(existsSync).mockReturnValue(true);
it('should return existing server info if already running', async () => {
vi.mocked(secureFs.access).mockResolvedValue(undefined);
const mockProcess = createMockProcess();
vi.mocked(spawn).mockReturnValue(mockProcess as any);
const { getDevServerService } = await import(
"@/services/dev-server-service.js"
);
const { getDevServerService } = await import('@/services/dev-server-service.js');
const service = getDevServerService();
// Start first server
@@ -230,18 +201,16 @@ describe("dev-server-service.ts", () => {
// Try to start again - should return existing
const result2 = await service.startDevServer(testDir, testDir);
expect(result2.success).toBe(true);
expect(result2.result?.message).toContain("already running");
expect(result2.result?.message).toContain('already running');
});
it("should start dev server successfully", async () => {
vi.mocked(existsSync).mockReturnValue(true);
it('should start dev server successfully', async () => {
vi.mocked(secureFs.access).mockResolvedValue(undefined);
const mockProcess = createMockProcess();
vi.mocked(spawn).mockReturnValue(mockProcess as any);
const { getDevServerService } = await import(
"@/services/dev-server-service.js"
);
const { getDevServerService } = await import('@/services/dev-server-service.js');
const service = getDevServerService();
const result = await service.startDevServer(testDir, testDir);
@@ -249,32 +218,28 @@ describe("dev-server-service.ts", () => {
expect(result.success).toBe(true);
expect(result.result).toBeDefined();
expect(result.result?.port).toBeGreaterThanOrEqual(3001);
expect(result.result?.url).toContain("http://localhost:");
expect(result.result?.url).toContain('http://localhost:');
});
});
describe("stopDevServer", () => {
it("should return success if server not found", async () => {
const { getDevServerService } = await import(
"@/services/dev-server-service.js"
);
describe('stopDevServer', () => {
it('should return success if server not found', async () => {
const { getDevServerService } = await import('@/services/dev-server-service.js');
const service = getDevServerService();
const result = await service.stopDevServer("/nonexistent/path");
const result = await service.stopDevServer('/nonexistent/path');
expect(result.success).toBe(true);
expect(result.result?.message).toContain("already stopped");
expect(result.result?.message).toContain('already stopped');
});
it("should stop a running server", async () => {
vi.mocked(existsSync).mockReturnValue(true);
it('should stop a running server', async () => {
vi.mocked(secureFs.access).mockResolvedValue(undefined);
const mockProcess = createMockProcess();
vi.mocked(spawn).mockReturnValue(mockProcess as any);
const { getDevServerService } = await import(
"@/services/dev-server-service.js"
);
const { getDevServerService } = await import('@/services/dev-server-service.js');
const service = getDevServerService();
// Start server
@@ -284,15 +249,13 @@ describe("dev-server-service.ts", () => {
const result = await service.stopDevServer(testDir);
expect(result.success).toBe(true);
expect(mockProcess.kill).toHaveBeenCalledWith("SIGTERM");
expect(mockProcess.kill).toHaveBeenCalledWith('SIGTERM');
});
});
describe("listDevServers", () => {
it("should return empty list when no servers running", async () => {
const { getDevServerService } = await import(
"@/services/dev-server-service.js"
);
describe('listDevServers', () => {
it('should return empty list when no servers running', async () => {
const { getDevServerService } = await import('@/services/dev-server-service.js');
const service = getDevServerService();
const result = service.listDevServers();
@@ -301,15 +264,13 @@ describe("dev-server-service.ts", () => {
expect(result.result.servers).toEqual([]);
});
it("should list running servers", async () => {
vi.mocked(existsSync).mockReturnValue(true);
it('should list running servers', async () => {
vi.mocked(secureFs.access).mockResolvedValue(undefined);
const mockProcess = createMockProcess();
vi.mocked(spawn).mockReturnValue(mockProcess as any);
const { getDevServerService } = await import(
"@/services/dev-server-service.js"
);
const { getDevServerService } = await import('@/services/dev-server-service.js');
const service = getDevServerService();
await service.startDevServer(testDir, testDir);
@@ -322,25 +283,21 @@ describe("dev-server-service.ts", () => {
});
});
describe("isRunning", () => {
it("should return false for non-running server", async () => {
const { getDevServerService } = await import(
"@/services/dev-server-service.js"
);
describe('isRunning', () => {
it('should return false for non-running server', async () => {
const { getDevServerService } = await import('@/services/dev-server-service.js');
const service = getDevServerService();
expect(service.isRunning("/some/path")).toBe(false);
expect(service.isRunning('/some/path')).toBe(false);
});
it("should return true for running server", async () => {
vi.mocked(existsSync).mockReturnValue(true);
it('should return true for running server', async () => {
vi.mocked(secureFs.access).mockResolvedValue(undefined);
const mockProcess = createMockProcess();
vi.mocked(spawn).mockReturnValue(mockProcess as any);
const { getDevServerService } = await import(
"@/services/dev-server-service.js"
);
const { getDevServerService } = await import('@/services/dev-server-service.js');
const service = getDevServerService();
await service.startDevServer(testDir, testDir);
@@ -349,25 +306,21 @@ describe("dev-server-service.ts", () => {
});
});
describe("getServerInfo", () => {
it("should return undefined for non-running server", async () => {
const { getDevServerService } = await import(
"@/services/dev-server-service.js"
);
describe('getServerInfo', () => {
it('should return undefined for non-running server', async () => {
const { getDevServerService } = await import('@/services/dev-server-service.js');
const service = getDevServerService();
expect(service.getServerInfo("/some/path")).toBeUndefined();
expect(service.getServerInfo('/some/path')).toBeUndefined();
});
it("should return info for running server", async () => {
vi.mocked(existsSync).mockReturnValue(true);
it('should return info for running server', async () => {
vi.mocked(secureFs.access).mockResolvedValue(undefined);
const mockProcess = createMockProcess();
vi.mocked(spawn).mockReturnValue(mockProcess as any);
const { getDevServerService } = await import(
"@/services/dev-server-service.js"
);
const { getDevServerService } = await import('@/services/dev-server-service.js');
const service = getDevServerService();
await service.startDevServer(testDir, testDir);
@@ -379,16 +332,14 @@ describe("dev-server-service.ts", () => {
});
});
describe("getAllocatedPorts", () => {
it("should return allocated ports", async () => {
vi.mocked(existsSync).mockReturnValue(true);
describe('getAllocatedPorts', () => {
it('should return allocated ports', async () => {
vi.mocked(secureFs.access).mockResolvedValue(undefined);
const mockProcess = createMockProcess();
vi.mocked(spawn).mockReturnValue(mockProcess as any);
const { getDevServerService } = await import(
"@/services/dev-server-service.js"
);
const { getDevServerService } = await import('@/services/dev-server-service.js');
const service = getDevServerService();
await service.startDevServer(testDir, testDir);
@@ -399,16 +350,14 @@ describe("dev-server-service.ts", () => {
});
});
describe("stopAll", () => {
it("should stop all running servers", async () => {
vi.mocked(existsSync).mockReturnValue(true);
describe('stopAll', () => {
it('should stop all running servers', async () => {
vi.mocked(secureFs.access).mockResolvedValue(undefined);
const mockProcess = createMockProcess();
vi.mocked(spawn).mockReturnValue(mockProcess as any);
const { getDevServerService } = await import(
"@/services/dev-server-service.js"
);
const { getDevServerService } = await import('@/services/dev-server-service.js');
const service = getDevServerService();
await service.startDevServer(testDir, testDir);

View File

@@ -1,66 +1,66 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { FeatureLoader } from "@/services/feature-loader.js";
import * as fs from "fs/promises";
import path from "path";
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { FeatureLoader } from '@/services/feature-loader.js';
import * as fs from 'fs/promises';
import path from 'path';
vi.mock("fs/promises");
vi.mock('fs/promises');
describe("feature-loader.ts", () => {
describe('feature-loader.ts', () => {
let loader: FeatureLoader;
const testProjectPath = "/test/project";
const testProjectPath = '/test/project';
beforeEach(() => {
vi.clearAllMocks();
loader = new FeatureLoader();
});
describe("getFeaturesDir", () => {
it("should return features directory path", () => {
describe('getFeaturesDir', () => {
it('should return features directory path', () => {
const result = loader.getFeaturesDir(testProjectPath);
expect(result).toContain("test");
expect(result).toContain("project");
expect(result).toContain(".automaker");
expect(result).toContain("features");
expect(result).toContain('test');
expect(result).toContain('project');
expect(result).toContain('.automaker');
expect(result).toContain('features');
});
});
describe("getFeatureImagesDir", () => {
it("should return feature images directory path", () => {
const result = loader.getFeatureImagesDir(testProjectPath, "feature-123");
expect(result).toContain("features");
expect(result).toContain("feature-123");
expect(result).toContain("images");
describe('getFeatureImagesDir', () => {
it('should return feature images directory path', () => {
const result = loader.getFeatureImagesDir(testProjectPath, 'feature-123');
expect(result).toContain('features');
expect(result).toContain('feature-123');
expect(result).toContain('images');
});
});
describe("getFeatureDir", () => {
it("should return feature directory path", () => {
const result = loader.getFeatureDir(testProjectPath, "feature-123");
expect(result).toContain("features");
expect(result).toContain("feature-123");
describe('getFeatureDir', () => {
it('should return feature directory path', () => {
const result = loader.getFeatureDir(testProjectPath, 'feature-123');
expect(result).toContain('features');
expect(result).toContain('feature-123');
});
});
describe("getFeatureJsonPath", () => {
it("should return feature.json path", () => {
const result = loader.getFeatureJsonPath(testProjectPath, "feature-123");
expect(result).toContain("features");
expect(result).toContain("feature-123");
expect(result).toContain("feature.json");
describe('getFeatureJsonPath', () => {
it('should return feature.json path', () => {
const result = loader.getFeatureJsonPath(testProjectPath, 'feature-123');
expect(result).toContain('features');
expect(result).toContain('feature-123');
expect(result).toContain('feature.json');
});
});
describe("getAgentOutputPath", () => {
it("should return agent-output.md path", () => {
const result = loader.getAgentOutputPath(testProjectPath, "feature-123");
expect(result).toContain("features");
expect(result).toContain("feature-123");
expect(result).toContain("agent-output.md");
describe('getAgentOutputPath', () => {
it('should return agent-output.md path', () => {
const result = loader.getAgentOutputPath(testProjectPath, 'feature-123');
expect(result).toContain('features');
expect(result).toContain('feature-123');
expect(result).toContain('agent-output.md');
});
});
describe("generateFeatureId", () => {
it("should generate unique feature ID with timestamp", () => {
describe('generateFeatureId', () => {
it('should generate unique feature ID with timestamp', () => {
const id1 = loader.generateFeatureId();
const id2 = loader.generateFeatureId();
@@ -75,372 +75,371 @@ describe("feature-loader.ts", () => {
});
});
describe("getAll", () => {
describe('getAll', () => {
it("should return empty array when features directory doesn't exist", async () => {
vi.mocked(fs.access).mockRejectedValue(new Error("ENOENT"));
vi.mocked(fs.access).mockRejectedValue(new Error('ENOENT'));
const result = await loader.getAll(testProjectPath);
expect(result).toEqual([]);
});
it("should load all features from feature directories", async () => {
it('should load all features from feature directories', async () => {
vi.mocked(fs.access).mockResolvedValue(undefined);
vi.mocked(fs.readdir).mockResolvedValue([
{ name: "feature-1", isDirectory: () => true } as any,
{ name: "feature-2", isDirectory: () => true } as any,
{ name: "file.txt", isDirectory: () => false } as any,
{ name: 'feature-1', isDirectory: () => true } as any,
{ name: 'feature-2', isDirectory: () => true } as any,
{ name: 'file.txt', isDirectory: () => false } as any,
]);
vi.mocked(fs.readFile)
.mockResolvedValueOnce(
JSON.stringify({
id: "feature-1",
category: "ui",
description: "Feature 1",
id: 'feature-1',
category: 'ui',
description: 'Feature 1',
})
)
.mockResolvedValueOnce(
JSON.stringify({
id: "feature-2",
category: "backend",
description: "Feature 2",
id: 'feature-2',
category: 'backend',
description: 'Feature 2',
})
);
const result = await loader.getAll(testProjectPath);
expect(result).toHaveLength(2);
expect(result[0].id).toBe("feature-1");
expect(result[1].id).toBe("feature-2");
expect(result[0].id).toBe('feature-1');
expect(result[1].id).toBe('feature-2');
});
it("should skip features without id field", async () => {
it('should skip features without id field', async () => {
vi.mocked(fs.access).mockResolvedValue(undefined);
vi.mocked(fs.readdir).mockResolvedValue([
{ name: "feature-1", isDirectory: () => true } as any,
{ name: "feature-2", isDirectory: () => true } as any,
{ name: 'feature-1', isDirectory: () => true } as any,
{ name: 'feature-2', isDirectory: () => true } as any,
]);
const consoleSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
vi.mocked(fs.readFile)
.mockResolvedValueOnce(
JSON.stringify({
category: "ui",
description: "Missing ID",
category: 'ui',
description: 'Missing ID',
})
)
.mockResolvedValueOnce(
JSON.stringify({
id: "feature-2",
category: "backend",
description: "Feature 2",
id: 'feature-2',
category: 'backend',
description: 'Feature 2',
})
);
const result = await loader.getAll(testProjectPath);
expect(result).toHaveLength(1);
expect(result[0].id).toBe("feature-2");
expect(result[0].id).toBe('feature-2');
expect(consoleSpy).toHaveBeenCalledWith(
'[FeatureLoader]',
expect.stringContaining("missing required 'id' field")
);
consoleSpy.mockRestore();
});
it("should skip features with missing feature.json", async () => {
it('should skip features with missing feature.json', async () => {
vi.mocked(fs.access).mockResolvedValue(undefined);
vi.mocked(fs.readdir).mockResolvedValue([
{ name: "feature-1", isDirectory: () => true } as any,
{ name: "feature-2", isDirectory: () => true } as any,
{ name: 'feature-1', isDirectory: () => true } as any,
{ name: 'feature-2', isDirectory: () => true } as any,
]);
const error: any = new Error("File not found");
error.code = "ENOENT";
const error: any = new Error('File not found');
error.code = 'ENOENT';
vi.mocked(fs.readFile)
.mockRejectedValueOnce(error)
.mockResolvedValueOnce(
JSON.stringify({
id: "feature-2",
category: "backend",
description: "Feature 2",
id: 'feature-2',
category: 'backend',
description: 'Feature 2',
})
);
const result = await loader.getAll(testProjectPath);
expect(result).toHaveLength(1);
expect(result[0].id).toBe("feature-2");
expect(result[0].id).toBe('feature-2');
});
it("should handle malformed JSON gracefully", async () => {
it('should handle malformed JSON gracefully', async () => {
vi.mocked(fs.access).mockResolvedValue(undefined);
vi.mocked(fs.readdir).mockResolvedValue([
{ name: "feature-1", isDirectory: () => true } as any,
{ name: 'feature-1', isDirectory: () => true } as any,
]);
const consoleSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
vi.mocked(fs.readFile).mockResolvedValue("invalid json{");
vi.mocked(fs.readFile).mockResolvedValue('invalid json{');
const result = await loader.getAll(testProjectPath);
expect(result).toEqual([]);
expect(consoleSpy).toHaveBeenCalled();
expect(consoleSpy).toHaveBeenCalledWith(
'[FeatureLoader]',
expect.stringContaining('Failed to parse feature.json')
);
consoleSpy.mockRestore();
});
it("should sort features by creation order (timestamp)", async () => {
it('should sort features by creation order (timestamp)', async () => {
vi.mocked(fs.access).mockResolvedValue(undefined);
vi.mocked(fs.readdir).mockResolvedValue([
{ name: "feature-3", isDirectory: () => true } as any,
{ name: "feature-1", isDirectory: () => true } as any,
{ name: "feature-2", isDirectory: () => true } as any,
{ name: 'feature-3', isDirectory: () => true } as any,
{ name: 'feature-1', isDirectory: () => true } as any,
{ name: 'feature-2', isDirectory: () => true } as any,
]);
vi.mocked(fs.readFile)
.mockResolvedValueOnce(
JSON.stringify({
id: "feature-3000-xyz",
category: "ui",
id: 'feature-3000-xyz',
category: 'ui',
})
)
.mockResolvedValueOnce(
JSON.stringify({
id: "feature-1000-abc",
category: "ui",
id: 'feature-1000-abc',
category: 'ui',
})
)
.mockResolvedValueOnce(
JSON.stringify({
id: "feature-2000-def",
category: "ui",
id: 'feature-2000-def',
category: 'ui',
})
);
const result = await loader.getAll(testProjectPath);
expect(result).toHaveLength(3);
expect(result[0].id).toBe("feature-1000-abc");
expect(result[1].id).toBe("feature-2000-def");
expect(result[2].id).toBe("feature-3000-xyz");
expect(result[0].id).toBe('feature-1000-abc');
expect(result[1].id).toBe('feature-2000-def');
expect(result[2].id).toBe('feature-3000-xyz');
});
});
describe("get", () => {
it("should return feature by ID", async () => {
describe('get', () => {
it('should return feature by ID', async () => {
const featureData = {
id: "feature-123",
category: "ui",
description: "Test feature",
id: 'feature-123',
category: 'ui',
description: 'Test feature',
};
vi.mocked(fs.readFile).mockResolvedValue(JSON.stringify(featureData));
const result = await loader.get(testProjectPath, "feature-123");
const result = await loader.get(testProjectPath, 'feature-123');
expect(result).toEqual(featureData);
});
it("should return null when feature doesn't exist", async () => {
const error: any = new Error("File not found");
error.code = "ENOENT";
const error: any = new Error('File not found');
error.code = 'ENOENT';
vi.mocked(fs.readFile).mockRejectedValue(error);
const result = await loader.get(testProjectPath, "feature-123");
const result = await loader.get(testProjectPath, 'feature-123');
expect(result).toBeNull();
});
it("should throw on other errors", async () => {
vi.mocked(fs.readFile).mockRejectedValue(new Error("Permission denied"));
it('should throw on other errors', async () => {
vi.mocked(fs.readFile).mockRejectedValue(new Error('Permission denied'));
await expect(
loader.get(testProjectPath, "feature-123")
).rejects.toThrow("Permission denied");
await expect(loader.get(testProjectPath, 'feature-123')).rejects.toThrow('Permission denied');
});
});
describe("create", () => {
it("should create new feature", async () => {
describe('create', () => {
it('should create new feature', async () => {
vi.mocked(fs.mkdir).mockResolvedValue(undefined);
vi.mocked(fs.writeFile).mockResolvedValue(undefined);
const featureData = {
category: "ui",
description: "New feature",
category: 'ui',
description: 'New feature',
};
const result = await loader.create(testProjectPath, featureData);
expect(result).toMatchObject({
category: "ui",
description: "New feature",
category: 'ui',
description: 'New feature',
id: expect.stringMatching(/^feature-/),
});
expect(fs.writeFile).toHaveBeenCalled();
});
it("should use provided ID if given", async () => {
it('should use provided ID if given', async () => {
vi.mocked(fs.mkdir).mockResolvedValue(undefined);
vi.mocked(fs.writeFile).mockResolvedValue(undefined);
const result = await loader.create(testProjectPath, {
id: "custom-id",
category: "ui",
description: "Test",
id: 'custom-id',
category: 'ui',
description: 'Test',
});
expect(result.id).toBe("custom-id");
expect(result.id).toBe('custom-id');
});
it("should set default category if not provided", async () => {
it('should set default category if not provided', async () => {
vi.mocked(fs.mkdir).mockResolvedValue(undefined);
vi.mocked(fs.writeFile).mockResolvedValue(undefined);
const result = await loader.create(testProjectPath, {
description: "Test",
description: 'Test',
});
expect(result.category).toBe("Uncategorized");
expect(result.category).toBe('Uncategorized');
});
});
describe("update", () => {
it("should update existing feature", async () => {
describe('update', () => {
it('should update existing feature', async () => {
vi.mocked(fs.readFile).mockResolvedValue(
JSON.stringify({
id: "feature-123",
category: "ui",
description: "Old description",
id: 'feature-123',
category: 'ui',
description: 'Old description',
})
);
vi.mocked(fs.writeFile).mockResolvedValue(undefined);
const result = await loader.update(testProjectPath, "feature-123", {
description: "New description",
const result = await loader.update(testProjectPath, 'feature-123', {
description: 'New description',
});
expect(result.description).toBe("New description");
expect(result.category).toBe("ui");
expect(result.description).toBe('New description');
expect(result.category).toBe('ui');
expect(fs.writeFile).toHaveBeenCalled();
});
it("should throw if feature doesn't exist", async () => {
const error: any = new Error("File not found");
error.code = "ENOENT";
const error: any = new Error('File not found');
error.code = 'ENOENT';
vi.mocked(fs.readFile).mockRejectedValue(error);
await expect(
loader.update(testProjectPath, "feature-123", {})
).rejects.toThrow("not found");
await expect(loader.update(testProjectPath, 'feature-123', {})).rejects.toThrow('not found');
});
});
describe("delete", () => {
it("should delete feature directory", async () => {
describe('delete', () => {
it('should delete feature directory', async () => {
vi.mocked(fs.rm).mockResolvedValue(undefined);
const result = await loader.delete(testProjectPath, "feature-123");
const result = await loader.delete(testProjectPath, 'feature-123');
expect(result).toBe(true);
expect(fs.rm).toHaveBeenCalledWith(
expect.stringContaining("feature-123"),
{ recursive: true, force: true }
);
expect(fs.rm).toHaveBeenCalledWith(expect.stringContaining('feature-123'), {
recursive: true,
force: true,
});
});
it("should return false on error", async () => {
vi.mocked(fs.rm).mockRejectedValue(new Error("Permission denied"));
it('should return false on error', async () => {
vi.mocked(fs.rm).mockRejectedValue(new Error('Permission denied'));
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const result = await loader.delete(testProjectPath, "feature-123");
const result = await loader.delete(testProjectPath, 'feature-123');
expect(result).toBe(false);
expect(consoleSpy).toHaveBeenCalledWith(
'[FeatureLoader]',
expect.stringContaining('Failed to delete feature'),
expect.objectContaining({ message: 'Permission denied' })
);
consoleSpy.mockRestore();
});
});
describe("getAgentOutput", () => {
it("should return agent output content", async () => {
vi.mocked(fs.readFile).mockResolvedValue("Agent output content");
describe('getAgentOutput', () => {
it('should return agent output content', async () => {
vi.mocked(fs.readFile).mockResolvedValue('Agent output content');
const result = await loader.getAgentOutput(testProjectPath, "feature-123");
const result = await loader.getAgentOutput(testProjectPath, 'feature-123');
expect(result).toBe("Agent output content");
expect(result).toBe('Agent output content');
});
it("should return null when file doesn't exist", async () => {
const error: any = new Error("File not found");
error.code = "ENOENT";
const error: any = new Error('File not found');
error.code = 'ENOENT';
vi.mocked(fs.readFile).mockRejectedValue(error);
const result = await loader.getAgentOutput(testProjectPath, "feature-123");
const result = await loader.getAgentOutput(testProjectPath, 'feature-123');
expect(result).toBeNull();
});
it("should throw on other errors", async () => {
vi.mocked(fs.readFile).mockRejectedValue(new Error("Permission denied"));
it('should throw on other errors', async () => {
vi.mocked(fs.readFile).mockRejectedValue(new Error('Permission denied'));
await expect(
loader.getAgentOutput(testProjectPath, "feature-123")
).rejects.toThrow("Permission denied");
await expect(loader.getAgentOutput(testProjectPath, 'feature-123')).rejects.toThrow(
'Permission denied'
);
});
});
describe("saveAgentOutput", () => {
it("should save agent output to file", async () => {
describe('saveAgentOutput', () => {
it('should save agent output to file', async () => {
vi.mocked(fs.mkdir).mockResolvedValue(undefined);
vi.mocked(fs.writeFile).mockResolvedValue(undefined);
await loader.saveAgentOutput(
testProjectPath,
"feature-123",
"Output content"
);
await loader.saveAgentOutput(testProjectPath, 'feature-123', 'Output content');
expect(fs.writeFile).toHaveBeenCalledWith(
expect.stringContaining("agent-output.md"),
"Output content",
"utf-8"
expect.stringContaining('agent-output.md'),
'Output content',
'utf-8'
);
});
});
describe("deleteAgentOutput", () => {
it("should delete agent output file", async () => {
describe('deleteAgentOutput', () => {
it('should delete agent output file', async () => {
vi.mocked(fs.unlink).mockResolvedValue(undefined);
await loader.deleteAgentOutput(testProjectPath, "feature-123");
await loader.deleteAgentOutput(testProjectPath, 'feature-123');
expect(fs.unlink).toHaveBeenCalledWith(
expect.stringContaining("agent-output.md")
);
expect(fs.unlink).toHaveBeenCalledWith(expect.stringContaining('agent-output.md'));
});
it("should handle missing file gracefully", async () => {
const error: any = new Error("File not found");
error.code = "ENOENT";
it('should handle missing file gracefully', async () => {
const error: any = new Error('File not found');
error.code = 'ENOENT';
vi.mocked(fs.unlink).mockRejectedValue(error);
// Should not throw
await expect(
loader.deleteAgentOutput(testProjectPath, "feature-123")
loader.deleteAgentOutput(testProjectPath, 'feature-123')
).resolves.toBeUndefined();
});
it("should throw on other errors", async () => {
vi.mocked(fs.unlink).mockRejectedValue(new Error("Permission denied"));
it('should throw on other errors', async () => {
vi.mocked(fs.unlink).mockRejectedValue(new Error('Permission denied'));
await expect(
loader.deleteAgentOutput(testProjectPath, "feature-123")
).rejects.toThrow("Permission denied");
await expect(loader.deleteAgentOutput(testProjectPath, 'feature-123')).rejects.toThrow(
'Permission denied'
);
});
});
});

View File

@@ -0,0 +1,611 @@
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);
});
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 });
});
});
});