mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-02-02 20:43:36 +00:00
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:
@@ -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();
|
||||
});
|
||||
|
||||
644
apps/server/tests/unit/services/claude-usage-service.test.ts
Normal file
644
apps/server/tests/unit/services/claude-usage-service.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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'
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
611
apps/server/tests/unit/services/settings-service.test.ts
Normal file
611
apps/server/tests/unit/services/settings-service.test.ts
Normal 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 });
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user