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:
29
apps/server/tests/fixtures/messages.ts
vendored
29
apps/server/tests/fixtures/messages.ts
vendored
@@ -2,38 +2,33 @@
|
||||
* Message fixtures for testing providers and lib utilities
|
||||
*/
|
||||
|
||||
import type {
|
||||
ConversationMessage,
|
||||
ProviderMessage,
|
||||
ContentBlock,
|
||||
} from "../../src/providers/types.js";
|
||||
import type { ConversationMessage, ProviderMessage, ContentBlock } from '@automaker/types';
|
||||
|
||||
export const conversationHistoryFixture: ConversationMessage[] = [
|
||||
{
|
||||
role: "user",
|
||||
content: "Hello, can you help me?",
|
||||
role: 'user',
|
||||
content: 'Hello, can you help me?',
|
||||
},
|
||||
{
|
||||
role: "assistant",
|
||||
content: "Of course! How can I assist you today?",
|
||||
role: 'assistant',
|
||||
content: 'Of course! How can I assist you today?',
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
role: 'user',
|
||||
content: [
|
||||
{ type: "text", text: "What is in this image?" },
|
||||
{ type: 'text', text: 'What is in this image?' },
|
||||
{
|
||||
type: "image",
|
||||
source: { type: "base64", media_type: "image/png", data: "base64data" },
|
||||
type: 'image',
|
||||
source: { type: 'base64', media_type: 'image/png', data: 'base64data' },
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export const claudeProviderMessageFixture: ProviderMessage = {
|
||||
type: "assistant",
|
||||
type: 'assistant',
|
||||
message: {
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "This is a test response" }],
|
||||
role: 'assistant',
|
||||
content: [{ type: 'text', text: 'This is a test response' }],
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -3,12 +3,11 @@
|
||||
* Runs before each test file
|
||||
*/
|
||||
|
||||
import { vi, beforeEach } from "vitest";
|
||||
import { vi, beforeEach } from 'vitest';
|
||||
|
||||
// Set test environment variables
|
||||
process.env.NODE_ENV = "test";
|
||||
process.env.DATA_DIR = "/tmp/test-data";
|
||||
process.env.ALLOWED_PROJECT_DIRS = "/tmp/test-projects";
|
||||
process.env.NODE_ENV = 'test';
|
||||
process.env.DATA_DIR = '/tmp/test-data';
|
||||
|
||||
// Reset all mocks before each test
|
||||
beforeEach(() => {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import path from "path";
|
||||
import fs from "fs/promises";
|
||||
import os from "os";
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import path from 'path';
|
||||
import fs from 'fs/promises';
|
||||
import os from 'os';
|
||||
import {
|
||||
getAutomakerDir,
|
||||
getFeaturesDir,
|
||||
@@ -13,97 +13,89 @@ import {
|
||||
getAppSpecPath,
|
||||
getBranchTrackingPath,
|
||||
ensureAutomakerDir,
|
||||
} from "@/lib/automaker-paths.js";
|
||||
getGlobalSettingsPath,
|
||||
getCredentialsPath,
|
||||
getProjectSettingsPath,
|
||||
ensureDataDir,
|
||||
} from '@automaker/platform';
|
||||
|
||||
describe("automaker-paths.ts", () => {
|
||||
const projectPath = path.join("/test", "project");
|
||||
describe('automaker-paths.ts', () => {
|
||||
const projectPath = path.join('/test', 'project');
|
||||
|
||||
describe("getAutomakerDir", () => {
|
||||
it("should return path to .automaker directory", () => {
|
||||
expect(getAutomakerDir(projectPath)).toBe(
|
||||
path.join(projectPath, ".automaker")
|
||||
describe('getAutomakerDir', () => {
|
||||
it('should return path to .automaker directory', () => {
|
||||
expect(getAutomakerDir(projectPath)).toBe(path.join(projectPath, '.automaker'));
|
||||
});
|
||||
|
||||
it('should handle paths with trailing slashes', () => {
|
||||
const pathWithSlash = path.join('/test', 'project') + path.sep;
|
||||
expect(getAutomakerDir(pathWithSlash)).toBe(path.join(pathWithSlash, '.automaker'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('getFeaturesDir', () => {
|
||||
it('should return path to features directory', () => {
|
||||
expect(getFeaturesDir(projectPath)).toBe(path.join(projectPath, '.automaker', 'features'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('getFeatureDir', () => {
|
||||
it('should return path to specific feature directory', () => {
|
||||
expect(getFeatureDir(projectPath, 'feature-123')).toBe(
|
||||
path.join(projectPath, '.automaker', 'features', 'feature-123')
|
||||
);
|
||||
});
|
||||
|
||||
it("should handle paths with trailing slashes", () => {
|
||||
const pathWithSlash = path.join("/test", "project") + path.sep;
|
||||
expect(getAutomakerDir(pathWithSlash)).toBe(
|
||||
path.join(pathWithSlash, ".automaker")
|
||||
it('should handle feature IDs with special characters', () => {
|
||||
expect(getFeatureDir(projectPath, 'my-feature_v2')).toBe(
|
||||
path.join(projectPath, '.automaker', 'features', 'my-feature_v2')
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getFeaturesDir", () => {
|
||||
it("should return path to features directory", () => {
|
||||
expect(getFeaturesDir(projectPath)).toBe(
|
||||
path.join(projectPath, ".automaker", "features")
|
||||
describe('getFeatureImagesDir', () => {
|
||||
it('should return path to feature images directory', () => {
|
||||
expect(getFeatureImagesDir(projectPath, 'feature-123')).toBe(
|
||||
path.join(projectPath, '.automaker', 'features', 'feature-123', 'images')
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getFeatureDir", () => {
|
||||
it("should return path to specific feature directory", () => {
|
||||
expect(getFeatureDir(projectPath, "feature-123")).toBe(
|
||||
path.join(projectPath, ".automaker", "features", "feature-123")
|
||||
);
|
||||
});
|
||||
|
||||
it("should handle feature IDs with special characters", () => {
|
||||
expect(getFeatureDir(projectPath, "my-feature_v2")).toBe(
|
||||
path.join(projectPath, ".automaker", "features", "my-feature_v2")
|
||||
);
|
||||
describe('getBoardDir', () => {
|
||||
it('should return path to board directory', () => {
|
||||
expect(getBoardDir(projectPath)).toBe(path.join(projectPath, '.automaker', 'board'));
|
||||
});
|
||||
});
|
||||
|
||||
describe("getFeatureImagesDir", () => {
|
||||
it("should return path to feature images directory", () => {
|
||||
expect(getFeatureImagesDir(projectPath, "feature-123")).toBe(
|
||||
path.join(projectPath, ".automaker", "features", "feature-123", "images")
|
||||
);
|
||||
describe('getImagesDir', () => {
|
||||
it('should return path to images directory', () => {
|
||||
expect(getImagesDir(projectPath)).toBe(path.join(projectPath, '.automaker', 'images'));
|
||||
});
|
||||
});
|
||||
|
||||
describe("getBoardDir", () => {
|
||||
it("should return path to board directory", () => {
|
||||
expect(getBoardDir(projectPath)).toBe(
|
||||
path.join(projectPath, ".automaker", "board")
|
||||
);
|
||||
describe('getWorktreesDir', () => {
|
||||
it('should return path to worktrees directory', () => {
|
||||
expect(getWorktreesDir(projectPath)).toBe(path.join(projectPath, '.automaker', 'worktrees'));
|
||||
});
|
||||
});
|
||||
|
||||
describe("getImagesDir", () => {
|
||||
it("should return path to images directory", () => {
|
||||
expect(getImagesDir(projectPath)).toBe(
|
||||
path.join(projectPath, ".automaker", "images")
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getWorktreesDir", () => {
|
||||
it("should return path to worktrees directory", () => {
|
||||
expect(getWorktreesDir(projectPath)).toBe(
|
||||
path.join(projectPath, ".automaker", "worktrees")
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getAppSpecPath", () => {
|
||||
it("should return path to app_spec.txt file", () => {
|
||||
describe('getAppSpecPath', () => {
|
||||
it('should return path to app_spec.txt file', () => {
|
||||
expect(getAppSpecPath(projectPath)).toBe(
|
||||
path.join(projectPath, ".automaker", "app_spec.txt")
|
||||
path.join(projectPath, '.automaker', 'app_spec.txt')
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getBranchTrackingPath", () => {
|
||||
it("should return path to active-branches.json file", () => {
|
||||
describe('getBranchTrackingPath', () => {
|
||||
it('should return path to active-branches.json file', () => {
|
||||
expect(getBranchTrackingPath(projectPath)).toBe(
|
||||
path.join(projectPath, ".automaker", "active-branches.json")
|
||||
path.join(projectPath, '.automaker', 'active-branches.json')
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ensureAutomakerDir", () => {
|
||||
describe('ensureAutomakerDir', () => {
|
||||
let testDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
@@ -119,16 +111,16 @@ describe("automaker-paths.ts", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("should create automaker directory and return path", async () => {
|
||||
it('should create automaker directory and return path', async () => {
|
||||
const result = await ensureAutomakerDir(testDir);
|
||||
|
||||
expect(result).toBe(path.join(testDir, ".automaker"));
|
||||
expect(result).toBe(path.join(testDir, '.automaker'));
|
||||
const stats = await fs.stat(result);
|
||||
expect(stats.isDirectory()).toBe(true);
|
||||
});
|
||||
|
||||
it("should succeed if directory already exists", async () => {
|
||||
const automakerDir = path.join(testDir, ".automaker");
|
||||
it('should succeed if directory already exists', async () => {
|
||||
const automakerDir = path.join(testDir, '.automaker');
|
||||
await fs.mkdir(automakerDir, { recursive: true });
|
||||
|
||||
const result = await ensureAutomakerDir(testDir);
|
||||
@@ -136,4 +128,87 @@ describe("automaker-paths.ts", () => {
|
||||
expect(result).toBe(automakerDir);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getGlobalSettingsPath', () => {
|
||||
it('should return path to settings.json in data directory', () => {
|
||||
const dataDir = '/test/data';
|
||||
const result = getGlobalSettingsPath(dataDir);
|
||||
expect(result).toBe(path.join(dataDir, 'settings.json'));
|
||||
});
|
||||
|
||||
it('should handle paths with trailing slashes', () => {
|
||||
const dataDir = '/test/data' + path.sep;
|
||||
const result = getGlobalSettingsPath(dataDir);
|
||||
expect(result).toBe(path.join(dataDir, 'settings.json'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('getCredentialsPath', () => {
|
||||
it('should return path to credentials.json in data directory', () => {
|
||||
const dataDir = '/test/data';
|
||||
const result = getCredentialsPath(dataDir);
|
||||
expect(result).toBe(path.join(dataDir, 'credentials.json'));
|
||||
});
|
||||
|
||||
it('should handle paths with trailing slashes', () => {
|
||||
const dataDir = '/test/data' + path.sep;
|
||||
const result = getCredentialsPath(dataDir);
|
||||
expect(result).toBe(path.join(dataDir, 'credentials.json'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('getProjectSettingsPath', () => {
|
||||
it('should return path to settings.json in project .automaker directory', () => {
|
||||
const projectPath = '/test/project';
|
||||
const result = getProjectSettingsPath(projectPath);
|
||||
expect(result).toBe(path.join(projectPath, '.automaker', 'settings.json'));
|
||||
});
|
||||
|
||||
it('should handle paths with trailing slashes', () => {
|
||||
const projectPath = '/test/project' + path.sep;
|
||||
const result = getProjectSettingsPath(projectPath);
|
||||
expect(result).toBe(path.join(projectPath, '.automaker', 'settings.json'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('ensureDataDir', () => {
|
||||
let testDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
testDir = path.join(os.tmpdir(), `data-dir-test-${Date.now()}`);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
try {
|
||||
await fs.rm(testDir, { recursive: true, force: true });
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
});
|
||||
|
||||
it('should create data directory and return path', async () => {
|
||||
const result = await ensureDataDir(testDir);
|
||||
|
||||
expect(result).toBe(testDir);
|
||||
const stats = await fs.stat(testDir);
|
||||
expect(stats.isDirectory()).toBe(true);
|
||||
});
|
||||
|
||||
it('should succeed if directory already exists', async () => {
|
||||
await fs.mkdir(testDir, { recursive: true });
|
||||
|
||||
const result = await ensureDataDir(testDir);
|
||||
|
||||
expect(result).toBe(testDir);
|
||||
});
|
||||
|
||||
it('should create nested directories', async () => {
|
||||
const nestedDir = path.join(testDir, 'nested', 'deep');
|
||||
const result = await ensureDataDir(nestedDir);
|
||||
|
||||
expect(result).toBe(nestedDir);
|
||||
const stats = await fs.stat(nestedDir);
|
||||
expect(stats.isDirectory()).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,146 +1,146 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
extractTextFromContent,
|
||||
normalizeContentBlocks,
|
||||
formatHistoryAsText,
|
||||
convertHistoryToMessages,
|
||||
} from "@/lib/conversation-utils.js";
|
||||
import { conversationHistoryFixture } from "../../fixtures/messages.js";
|
||||
} from '@automaker/utils';
|
||||
import { conversationHistoryFixture } from '../../fixtures/messages.js';
|
||||
|
||||
describe("conversation-utils.ts", () => {
|
||||
describe("extractTextFromContent", () => {
|
||||
it("should return string content as-is", () => {
|
||||
const result = extractTextFromContent("Hello world");
|
||||
expect(result).toBe("Hello world");
|
||||
describe('conversation-utils.ts', () => {
|
||||
describe('extractTextFromContent', () => {
|
||||
it('should return string content as-is', () => {
|
||||
const result = extractTextFromContent('Hello world');
|
||||
expect(result).toBe('Hello world');
|
||||
});
|
||||
|
||||
it("should extract text from single text block", () => {
|
||||
const content = [{ type: "text", text: "Hello" }];
|
||||
it('should extract text from single text block', () => {
|
||||
const content = [{ type: 'text', text: 'Hello' }];
|
||||
const result = extractTextFromContent(content);
|
||||
expect(result).toBe("Hello");
|
||||
expect(result).toBe('Hello');
|
||||
});
|
||||
|
||||
it("should extract and join multiple text blocks with newlines", () => {
|
||||
it('should extract and join multiple text blocks with newlines', () => {
|
||||
const content = [
|
||||
{ type: "text", text: "First block" },
|
||||
{ type: "text", text: "Second block" },
|
||||
{ type: "text", text: "Third block" },
|
||||
{ type: 'text', text: 'First block' },
|
||||
{ type: 'text', text: 'Second block' },
|
||||
{ type: 'text', text: 'Third block' },
|
||||
];
|
||||
const result = extractTextFromContent(content);
|
||||
expect(result).toBe("First block\nSecond block\nThird block");
|
||||
expect(result).toBe('First block\nSecond block\nThird block');
|
||||
});
|
||||
|
||||
it("should ignore non-text blocks", () => {
|
||||
it('should ignore non-text blocks', () => {
|
||||
const content = [
|
||||
{ type: "text", text: "Text content" },
|
||||
{ type: "image", source: { type: "base64", data: "abc" } },
|
||||
{ type: "text", text: "More text" },
|
||||
{ type: "tool_use", name: "bash", input: {} },
|
||||
{ type: 'text', text: 'Text content' },
|
||||
{ type: 'image', source: { type: 'base64', data: 'abc' } },
|
||||
{ type: 'text', text: 'More text' },
|
||||
{ type: 'tool_use', name: 'bash', input: {} },
|
||||
];
|
||||
const result = extractTextFromContent(content);
|
||||
expect(result).toBe("Text content\nMore text");
|
||||
expect(result).toBe('Text content\nMore text');
|
||||
});
|
||||
|
||||
it("should handle blocks without text property", () => {
|
||||
it('should handle blocks without text property', () => {
|
||||
const content = [
|
||||
{ type: "text", text: "Valid" },
|
||||
{ type: "text" } as any,
|
||||
{ type: "text", text: "Also valid" },
|
||||
{ type: 'text', text: 'Valid' },
|
||||
{ type: 'text' } as any,
|
||||
{ type: 'text', text: 'Also valid' },
|
||||
];
|
||||
const result = extractTextFromContent(content);
|
||||
expect(result).toBe("Valid\n\nAlso valid");
|
||||
expect(result).toBe('Valid\n\nAlso valid');
|
||||
});
|
||||
|
||||
it("should handle empty array", () => {
|
||||
it('should handle empty array', () => {
|
||||
const result = extractTextFromContent([]);
|
||||
expect(result).toBe("");
|
||||
expect(result).toBe('');
|
||||
});
|
||||
|
||||
it("should handle array with only non-text blocks", () => {
|
||||
it('should handle array with only non-text blocks', () => {
|
||||
const content = [
|
||||
{ type: "image", source: {} },
|
||||
{ type: "tool_use", name: "test" },
|
||||
{ type: 'image', source: {} },
|
||||
{ type: 'tool_use', name: 'test' },
|
||||
];
|
||||
const result = extractTextFromContent(content);
|
||||
expect(result).toBe("");
|
||||
expect(result).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalizeContentBlocks", () => {
|
||||
it("should convert string to content block array", () => {
|
||||
const result = normalizeContentBlocks("Hello");
|
||||
expect(result).toEqual([{ type: "text", text: "Hello" }]);
|
||||
describe('normalizeContentBlocks', () => {
|
||||
it('should convert string to content block array', () => {
|
||||
const result = normalizeContentBlocks('Hello');
|
||||
expect(result).toEqual([{ type: 'text', text: 'Hello' }]);
|
||||
});
|
||||
|
||||
it("should return array content as-is", () => {
|
||||
it('should return array content as-is', () => {
|
||||
const content = [
|
||||
{ type: "text", text: "Hello" },
|
||||
{ type: "image", source: {} },
|
||||
{ type: 'text', text: 'Hello' },
|
||||
{ type: 'image', source: {} },
|
||||
];
|
||||
const result = normalizeContentBlocks(content);
|
||||
expect(result).toBe(content);
|
||||
expect(result).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("should handle empty string", () => {
|
||||
const result = normalizeContentBlocks("");
|
||||
expect(result).toEqual([{ type: "text", text: "" }]);
|
||||
it('should handle empty string', () => {
|
||||
const result = normalizeContentBlocks('');
|
||||
expect(result).toEqual([{ type: 'text', text: '' }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatHistoryAsText", () => {
|
||||
it("should return empty string for empty history", () => {
|
||||
describe('formatHistoryAsText', () => {
|
||||
it('should return empty string for empty history', () => {
|
||||
const result = formatHistoryAsText([]);
|
||||
expect(result).toBe("");
|
||||
expect(result).toBe('');
|
||||
});
|
||||
|
||||
it("should format single user message", () => {
|
||||
const history = [{ role: "user" as const, content: "Hello" }];
|
||||
it('should format single user message', () => {
|
||||
const history = [{ role: 'user' as const, content: 'Hello' }];
|
||||
const result = formatHistoryAsText(history);
|
||||
|
||||
expect(result).toContain("Previous conversation:");
|
||||
expect(result).toContain("User: Hello");
|
||||
expect(result).toContain("---");
|
||||
expect(result).toContain('Previous conversation:');
|
||||
expect(result).toContain('User: Hello');
|
||||
expect(result).toContain('---');
|
||||
});
|
||||
|
||||
it("should format single assistant message", () => {
|
||||
const history = [{ role: "assistant" as const, content: "Hi there" }];
|
||||
it('should format single assistant message', () => {
|
||||
const history = [{ role: 'assistant' as const, content: 'Hi there' }];
|
||||
const result = formatHistoryAsText(history);
|
||||
|
||||
expect(result).toContain("Assistant: Hi there");
|
||||
expect(result).toContain('Assistant: Hi there');
|
||||
});
|
||||
|
||||
it("should format multiple messages with correct roles", () => {
|
||||
it('should format multiple messages with correct roles', () => {
|
||||
const history = conversationHistoryFixture.slice(0, 2);
|
||||
const result = formatHistoryAsText(history);
|
||||
|
||||
expect(result).toContain("User: Hello, can you help me?");
|
||||
expect(result).toContain("Assistant: Of course! How can I assist you today?");
|
||||
expect(result).toContain("---");
|
||||
expect(result).toContain('User: Hello, can you help me?');
|
||||
expect(result).toContain('Assistant: Of course! How can I assist you today?');
|
||||
expect(result).toContain('---');
|
||||
});
|
||||
|
||||
it("should handle messages with array content (multipart)", () => {
|
||||
it('should handle messages with array content (multipart)', () => {
|
||||
const history = [conversationHistoryFixture[2]]; // Has text + image
|
||||
const result = formatHistoryAsText(history);
|
||||
|
||||
expect(result).toContain("What is in this image?");
|
||||
expect(result).not.toContain("base64"); // Should not include image data
|
||||
expect(result).toContain('What is in this image?');
|
||||
expect(result).not.toContain('base64'); // Should not include image data
|
||||
});
|
||||
|
||||
it("should format all messages from fixture", () => {
|
||||
it('should format all messages from fixture', () => {
|
||||
const result = formatHistoryAsText(conversationHistoryFixture);
|
||||
|
||||
expect(result).toContain("Previous conversation:");
|
||||
expect(result).toContain("User: Hello, can you help me?");
|
||||
expect(result).toContain("Assistant: Of course!");
|
||||
expect(result).toContain("User: What is in this image?");
|
||||
expect(result).toContain("---");
|
||||
expect(result).toContain('Previous conversation:');
|
||||
expect(result).toContain('User: Hello, can you help me?');
|
||||
expect(result).toContain('Assistant: Of course!');
|
||||
expect(result).toContain('User: What is in this image?');
|
||||
expect(result).toContain('---');
|
||||
});
|
||||
|
||||
it("should separate messages with double newlines", () => {
|
||||
it('should separate messages with double newlines', () => {
|
||||
const history = [
|
||||
{ role: "user" as const, content: "First" },
|
||||
{ role: "assistant" as const, content: "Second" },
|
||||
{ role: 'user' as const, content: 'First' },
|
||||
{ role: 'assistant' as const, content: 'Second' },
|
||||
];
|
||||
const result = formatHistoryAsText(history);
|
||||
|
||||
@@ -148,73 +148,71 @@ describe("conversation-utils.ts", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("convertHistoryToMessages", () => {
|
||||
it("should convert empty history", () => {
|
||||
describe('convertHistoryToMessages', () => {
|
||||
it('should convert empty history', () => {
|
||||
const result = convertHistoryToMessages([]);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("should convert single message to SDK format", () => {
|
||||
const history = [{ role: "user" as const, content: "Hello" }];
|
||||
it('should convert single message to SDK format', () => {
|
||||
const history = [{ role: 'user' as const, content: 'Hello' }];
|
||||
const result = convertHistoryToMessages(history);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toMatchObject({
|
||||
type: "user",
|
||||
session_id: "",
|
||||
type: 'user',
|
||||
session_id: '',
|
||||
message: {
|
||||
role: "user",
|
||||
content: [{ type: "text", text: "Hello" }],
|
||||
role: 'user',
|
||||
content: [{ type: 'text', text: 'Hello' }],
|
||||
},
|
||||
parent_tool_use_id: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("should normalize string content to array", () => {
|
||||
const history = [{ role: "assistant" as const, content: "Response" }];
|
||||
it('should normalize string content to array', () => {
|
||||
const history = [{ role: 'assistant' as const, content: 'Response' }];
|
||||
const result = convertHistoryToMessages(history);
|
||||
|
||||
expect(result[0].message.content).toEqual([
|
||||
{ type: "text", text: "Response" },
|
||||
]);
|
||||
expect(result[0].message.content).toEqual([{ type: 'text', text: 'Response' }]);
|
||||
});
|
||||
|
||||
it("should preserve array content", () => {
|
||||
it('should preserve array content', () => {
|
||||
const history = [
|
||||
{
|
||||
role: "user" as const,
|
||||
role: 'user' as const,
|
||||
content: [
|
||||
{ type: "text", text: "Hello" },
|
||||
{ type: "image", source: {} },
|
||||
{ type: 'text', text: 'Hello' },
|
||||
{ type: 'image', source: {} },
|
||||
],
|
||||
},
|
||||
];
|
||||
const result = convertHistoryToMessages(history);
|
||||
|
||||
expect(result[0].message.content).toHaveLength(2);
|
||||
expect(result[0].message.content[0]).toEqual({ type: "text", text: "Hello" });
|
||||
expect(result[0].message.content[0]).toEqual({ type: 'text', text: 'Hello' });
|
||||
});
|
||||
|
||||
it("should convert multiple messages", () => {
|
||||
it('should convert multiple messages', () => {
|
||||
const history = conversationHistoryFixture.slice(0, 2);
|
||||
const result = convertHistoryToMessages(history);
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0].type).toBe("user");
|
||||
expect(result[1].type).toBe("assistant");
|
||||
expect(result[0].type).toBe('user');
|
||||
expect(result[1].type).toBe('assistant');
|
||||
});
|
||||
|
||||
it("should set correct fields for SDK format", () => {
|
||||
const history = [{ role: "user" as const, content: "Test" }];
|
||||
it('should set correct fields for SDK format', () => {
|
||||
const history = [{ role: 'user' as const, content: 'Test' }];
|
||||
const result = convertHistoryToMessages(history);
|
||||
|
||||
expect(result[0].session_id).toBe("");
|
||||
expect(result[0].session_id).toBe('');
|
||||
expect(result[0].parent_tool_use_id).toBeNull();
|
||||
expect(result[0].type).toBe("user");
|
||||
expect(result[0].message.role).toBe("user");
|
||||
expect(result[0].type).toBe('user');
|
||||
expect(result[0].message.role).toBe('user');
|
||||
});
|
||||
|
||||
it("should handle all messages from fixture", () => {
|
||||
it('should handle all messages from fixture', () => {
|
||||
const result = convertHistoryToMessages(conversationHistoryFixture);
|
||||
|
||||
expect(result).toHaveLength(3);
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
resolveDependencies,
|
||||
areDependenciesSatisfied,
|
||||
getBlockingDependencies,
|
||||
type DependencyResolutionResult,
|
||||
} from "@/lib/dependency-resolver.js";
|
||||
import type { Feature } from "@/services/feature-loader.js";
|
||||
} from '@automaker/dependency-resolver';
|
||||
import type { Feature } from '@automaker/types';
|
||||
|
||||
// Helper to create test features
|
||||
function createFeature(
|
||||
@@ -20,17 +20,17 @@ function createFeature(
|
||||
): Feature {
|
||||
return {
|
||||
id,
|
||||
category: options.category || "test",
|
||||
category: options.category || 'test',
|
||||
description: options.description || `Feature ${id}`,
|
||||
status: options.status || "backlog",
|
||||
status: options.status || 'backlog',
|
||||
priority: options.priority,
|
||||
dependencies: options.dependencies,
|
||||
};
|
||||
}
|
||||
|
||||
describe("dependency-resolver.ts", () => {
|
||||
describe("resolveDependencies", () => {
|
||||
it("should handle empty feature list", () => {
|
||||
describe('dependency-resolver.ts', () => {
|
||||
describe('resolveDependencies', () => {
|
||||
it('should handle empty feature list', () => {
|
||||
const result = resolveDependencies([]);
|
||||
|
||||
expect(result.orderedFeatures).toEqual([]);
|
||||
@@ -39,103 +39,103 @@ describe("dependency-resolver.ts", () => {
|
||||
expect(result.blockedFeatures.size).toBe(0);
|
||||
});
|
||||
|
||||
it("should handle features with no dependencies", () => {
|
||||
it('should handle features with no dependencies', () => {
|
||||
const features = [
|
||||
createFeature("f1", { priority: 1 }),
|
||||
createFeature("f2", { priority: 2 }),
|
||||
createFeature("f3", { priority: 3 }),
|
||||
createFeature('f1', { priority: 1 }),
|
||||
createFeature('f2', { priority: 2 }),
|
||||
createFeature('f3', { priority: 3 }),
|
||||
];
|
||||
|
||||
const result = resolveDependencies(features);
|
||||
|
||||
expect(result.orderedFeatures).toHaveLength(3);
|
||||
expect(result.orderedFeatures[0].id).toBe("f1"); // Highest priority first
|
||||
expect(result.orderedFeatures[1].id).toBe("f2");
|
||||
expect(result.orderedFeatures[2].id).toBe("f3");
|
||||
expect(result.orderedFeatures[0].id).toBe('f1'); // Highest priority first
|
||||
expect(result.orderedFeatures[1].id).toBe('f2');
|
||||
expect(result.orderedFeatures[2].id).toBe('f3');
|
||||
expect(result.circularDependencies).toEqual([]);
|
||||
expect(result.missingDependencies.size).toBe(0);
|
||||
expect(result.blockedFeatures.size).toBe(0);
|
||||
});
|
||||
|
||||
it("should order features by dependencies (simple chain)", () => {
|
||||
it('should order features by dependencies (simple chain)', () => {
|
||||
const features = [
|
||||
createFeature("f3", { dependencies: ["f2"] }),
|
||||
createFeature("f1"),
|
||||
createFeature("f2", { dependencies: ["f1"] }),
|
||||
createFeature('f3', { dependencies: ['f2'] }),
|
||||
createFeature('f1'),
|
||||
createFeature('f2', { dependencies: ['f1'] }),
|
||||
];
|
||||
|
||||
const result = resolveDependencies(features);
|
||||
|
||||
expect(result.orderedFeatures).toHaveLength(3);
|
||||
expect(result.orderedFeatures[0].id).toBe("f1");
|
||||
expect(result.orderedFeatures[1].id).toBe("f2");
|
||||
expect(result.orderedFeatures[2].id).toBe("f3");
|
||||
expect(result.orderedFeatures[0].id).toBe('f1');
|
||||
expect(result.orderedFeatures[1].id).toBe('f2');
|
||||
expect(result.orderedFeatures[2].id).toBe('f3');
|
||||
expect(result.circularDependencies).toEqual([]);
|
||||
});
|
||||
|
||||
it("should respect priority within same dependency level", () => {
|
||||
it('should respect priority within same dependency level', () => {
|
||||
const features = [
|
||||
createFeature("f1", { priority: 3, dependencies: ["base"] }),
|
||||
createFeature("f2", { priority: 1, dependencies: ["base"] }),
|
||||
createFeature("f3", { priority: 2, dependencies: ["base"] }),
|
||||
createFeature("base"),
|
||||
createFeature('f1', { priority: 3, dependencies: ['base'] }),
|
||||
createFeature('f2', { priority: 1, dependencies: ['base'] }),
|
||||
createFeature('f3', { priority: 2, dependencies: ['base'] }),
|
||||
createFeature('base'),
|
||||
];
|
||||
|
||||
const result = resolveDependencies(features);
|
||||
|
||||
expect(result.orderedFeatures[0].id).toBe("base");
|
||||
expect(result.orderedFeatures[1].id).toBe("f2"); // Priority 1
|
||||
expect(result.orderedFeatures[2].id).toBe("f3"); // Priority 2
|
||||
expect(result.orderedFeatures[3].id).toBe("f1"); // Priority 3
|
||||
expect(result.orderedFeatures[0].id).toBe('base');
|
||||
expect(result.orderedFeatures[1].id).toBe('f2'); // Priority 1
|
||||
expect(result.orderedFeatures[2].id).toBe('f3'); // Priority 2
|
||||
expect(result.orderedFeatures[3].id).toBe('f1'); // Priority 3
|
||||
});
|
||||
|
||||
it("should use default priority of 2 when not specified", () => {
|
||||
it('should use default priority of 2 when not specified', () => {
|
||||
const features = [
|
||||
createFeature("f1", { priority: 1 }),
|
||||
createFeature("f2"), // No priority = default 2
|
||||
createFeature("f3", { priority: 3 }),
|
||||
createFeature('f1', { priority: 1 }),
|
||||
createFeature('f2'), // No priority = default 2
|
||||
createFeature('f3', { priority: 3 }),
|
||||
];
|
||||
|
||||
const result = resolveDependencies(features);
|
||||
|
||||
expect(result.orderedFeatures[0].id).toBe("f1");
|
||||
expect(result.orderedFeatures[1].id).toBe("f2");
|
||||
expect(result.orderedFeatures[2].id).toBe("f3");
|
||||
expect(result.orderedFeatures[0].id).toBe('f1');
|
||||
expect(result.orderedFeatures[1].id).toBe('f2');
|
||||
expect(result.orderedFeatures[2].id).toBe('f3');
|
||||
});
|
||||
|
||||
it("should detect missing dependencies", () => {
|
||||
it('should detect missing dependencies', () => {
|
||||
const features = [
|
||||
createFeature("f1", { dependencies: ["missing1", "missing2"] }),
|
||||
createFeature("f2", { dependencies: ["f1", "missing3"] }),
|
||||
createFeature('f1', { dependencies: ['missing1', 'missing2'] }),
|
||||
createFeature('f2', { dependencies: ['f1', 'missing3'] }),
|
||||
];
|
||||
|
||||
const result = resolveDependencies(features);
|
||||
|
||||
expect(result.missingDependencies.size).toBe(2);
|
||||
expect(result.missingDependencies.get("f1")).toEqual(["missing1", "missing2"]);
|
||||
expect(result.missingDependencies.get("f2")).toEqual(["missing3"]);
|
||||
expect(result.missingDependencies.get('f1')).toEqual(['missing1', 'missing2']);
|
||||
expect(result.missingDependencies.get('f2')).toEqual(['missing3']);
|
||||
expect(result.orderedFeatures).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("should detect blocked features (incomplete dependencies)", () => {
|
||||
it('should detect blocked features (incomplete dependencies)', () => {
|
||||
const features = [
|
||||
createFeature("f1", { status: "in_progress" }),
|
||||
createFeature("f2", { status: "backlog", dependencies: ["f1"] }),
|
||||
createFeature("f3", { status: "completed" }),
|
||||
createFeature("f4", { status: "backlog", dependencies: ["f3"] }),
|
||||
createFeature('f1', { status: 'in_progress' }),
|
||||
createFeature('f2', { status: 'backlog', dependencies: ['f1'] }),
|
||||
createFeature('f3', { status: 'completed' }),
|
||||
createFeature('f4', { status: 'backlog', dependencies: ['f3'] }),
|
||||
];
|
||||
|
||||
const result = resolveDependencies(features);
|
||||
|
||||
expect(result.blockedFeatures.size).toBe(1);
|
||||
expect(result.blockedFeatures.get("f2")).toEqual(["f1"]);
|
||||
expect(result.blockedFeatures.has("f4")).toBe(false); // f3 is completed
|
||||
expect(result.blockedFeatures.get('f2')).toEqual(['f1']);
|
||||
expect(result.blockedFeatures.has('f4')).toBe(false); // f3 is completed
|
||||
});
|
||||
|
||||
it("should not block features whose dependencies are verified", () => {
|
||||
it('should not block features whose dependencies are verified', () => {
|
||||
const features = [
|
||||
createFeature("f1", { status: "verified" }),
|
||||
createFeature("f2", { status: "backlog", dependencies: ["f1"] }),
|
||||
createFeature('f1', { status: 'verified' }),
|
||||
createFeature('f2', { status: 'backlog', dependencies: ['f1'] }),
|
||||
];
|
||||
|
||||
const result = resolveDependencies(features);
|
||||
@@ -143,25 +143,25 @@ describe("dependency-resolver.ts", () => {
|
||||
expect(result.blockedFeatures.size).toBe(0);
|
||||
});
|
||||
|
||||
it("should detect circular dependencies (simple cycle)", () => {
|
||||
it('should detect circular dependencies (simple cycle)', () => {
|
||||
const features = [
|
||||
createFeature("f1", { dependencies: ["f2"] }),
|
||||
createFeature("f2", { dependencies: ["f1"] }),
|
||||
createFeature('f1', { dependencies: ['f2'] }),
|
||||
createFeature('f2', { dependencies: ['f1'] }),
|
||||
];
|
||||
|
||||
const result = resolveDependencies(features);
|
||||
|
||||
expect(result.circularDependencies).toHaveLength(1);
|
||||
expect(result.circularDependencies[0]).toContain("f1");
|
||||
expect(result.circularDependencies[0]).toContain("f2");
|
||||
expect(result.circularDependencies[0]).toContain('f1');
|
||||
expect(result.circularDependencies[0]).toContain('f2');
|
||||
expect(result.orderedFeatures).toHaveLength(2); // Features still included
|
||||
});
|
||||
|
||||
it("should detect circular dependencies (multi-node cycle)", () => {
|
||||
it('should detect circular dependencies (multi-node cycle)', () => {
|
||||
const features = [
|
||||
createFeature("f1", { dependencies: ["f3"] }),
|
||||
createFeature("f2", { dependencies: ["f1"] }),
|
||||
createFeature("f3", { dependencies: ["f2"] }),
|
||||
createFeature('f1', { dependencies: ['f3'] }),
|
||||
createFeature('f2', { dependencies: ['f1'] }),
|
||||
createFeature('f3', { dependencies: ['f2'] }),
|
||||
];
|
||||
|
||||
const result = resolveDependencies(features);
|
||||
@@ -170,47 +170,47 @@ describe("dependency-resolver.ts", () => {
|
||||
expect(result.orderedFeatures).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("should handle mixed valid and circular dependencies", () => {
|
||||
it('should handle mixed valid and circular dependencies', () => {
|
||||
const features = [
|
||||
createFeature("base"),
|
||||
createFeature("f1", { dependencies: ["base", "f2"] }),
|
||||
createFeature("f2", { dependencies: ["f1"] }), // Circular with f1
|
||||
createFeature("f3", { dependencies: ["base"] }),
|
||||
createFeature('base'),
|
||||
createFeature('f1', { dependencies: ['base', 'f2'] }),
|
||||
createFeature('f2', { dependencies: ['f1'] }), // Circular with f1
|
||||
createFeature('f3', { dependencies: ['base'] }),
|
||||
];
|
||||
|
||||
const result = resolveDependencies(features);
|
||||
|
||||
expect(result.circularDependencies.length).toBeGreaterThan(0);
|
||||
expect(result.orderedFeatures[0].id).toBe("base");
|
||||
expect(result.orderedFeatures[0].id).toBe('base');
|
||||
expect(result.orderedFeatures).toHaveLength(4);
|
||||
});
|
||||
|
||||
it("should handle complex dependency graph", () => {
|
||||
it('should handle complex dependency graph', () => {
|
||||
const features = [
|
||||
createFeature("ui", { dependencies: ["api", "auth"], priority: 1 }),
|
||||
createFeature("api", { dependencies: ["db"], priority: 2 }),
|
||||
createFeature("auth", { dependencies: ["db"], priority: 1 }),
|
||||
createFeature("db", { priority: 1 }),
|
||||
createFeature("tests", { dependencies: ["ui"], priority: 3 }),
|
||||
createFeature('ui', { dependencies: ['api', 'auth'], priority: 1 }),
|
||||
createFeature('api', { dependencies: ['db'], priority: 2 }),
|
||||
createFeature('auth', { dependencies: ['db'], priority: 1 }),
|
||||
createFeature('db', { priority: 1 }),
|
||||
createFeature('tests', { dependencies: ['ui'], priority: 3 }),
|
||||
];
|
||||
|
||||
const result = resolveDependencies(features);
|
||||
|
||||
const order = result.orderedFeatures.map(f => f.id);
|
||||
const order = result.orderedFeatures.map((f) => f.id);
|
||||
|
||||
expect(order[0]).toBe("db");
|
||||
expect(order.indexOf("db")).toBeLessThan(order.indexOf("api"));
|
||||
expect(order.indexOf("db")).toBeLessThan(order.indexOf("auth"));
|
||||
expect(order.indexOf("api")).toBeLessThan(order.indexOf("ui"));
|
||||
expect(order.indexOf("auth")).toBeLessThan(order.indexOf("ui"));
|
||||
expect(order.indexOf("ui")).toBeLessThan(order.indexOf("tests"));
|
||||
expect(order[0]).toBe('db');
|
||||
expect(order.indexOf('db')).toBeLessThan(order.indexOf('api'));
|
||||
expect(order.indexOf('db')).toBeLessThan(order.indexOf('auth'));
|
||||
expect(order.indexOf('api')).toBeLessThan(order.indexOf('ui'));
|
||||
expect(order.indexOf('auth')).toBeLessThan(order.indexOf('ui'));
|
||||
expect(order.indexOf('ui')).toBeLessThan(order.indexOf('tests'));
|
||||
expect(result.circularDependencies).toEqual([]);
|
||||
});
|
||||
|
||||
it("should handle features with empty dependencies array", () => {
|
||||
it('should handle features with empty dependencies array', () => {
|
||||
const features = [
|
||||
createFeature("f1", { dependencies: [] }),
|
||||
createFeature("f2", { dependencies: [] }),
|
||||
createFeature('f1', { dependencies: [] }),
|
||||
createFeature('f2', { dependencies: [] }),
|
||||
];
|
||||
|
||||
const result = resolveDependencies(features);
|
||||
@@ -220,22 +220,20 @@ describe("dependency-resolver.ts", () => {
|
||||
expect(result.blockedFeatures.size).toBe(0);
|
||||
});
|
||||
|
||||
it("should track multiple blocking dependencies", () => {
|
||||
it('should track multiple blocking dependencies', () => {
|
||||
const features = [
|
||||
createFeature("f1", { status: "in_progress" }),
|
||||
createFeature("f2", { status: "backlog" }),
|
||||
createFeature("f3", { status: "backlog", dependencies: ["f1", "f2"] }),
|
||||
createFeature('f1', { status: 'in_progress' }),
|
||||
createFeature('f2', { status: 'backlog' }),
|
||||
createFeature('f3', { status: 'backlog', dependencies: ['f1', 'f2'] }),
|
||||
];
|
||||
|
||||
const result = resolveDependencies(features);
|
||||
|
||||
expect(result.blockedFeatures.get("f3")).toEqual(["f1", "f2"]);
|
||||
expect(result.blockedFeatures.get('f3')).toEqual(['f1', 'f2']);
|
||||
});
|
||||
|
||||
it("should handle self-referencing dependency", () => {
|
||||
const features = [
|
||||
createFeature("f1", { dependencies: ["f1"] }),
|
||||
];
|
||||
it('should handle self-referencing dependency', () => {
|
||||
const features = [createFeature('f1', { dependencies: ['f1'] })];
|
||||
|
||||
const result = resolveDependencies(features);
|
||||
|
||||
@@ -244,195 +242,191 @@ describe("dependency-resolver.ts", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("areDependenciesSatisfied", () => {
|
||||
it("should return true for feature with no dependencies", () => {
|
||||
const feature = createFeature("f1");
|
||||
describe('areDependenciesSatisfied', () => {
|
||||
it('should return true for feature with no dependencies', () => {
|
||||
const feature = createFeature('f1');
|
||||
const allFeatures = [feature];
|
||||
|
||||
expect(areDependenciesSatisfied(feature, allFeatures)).toBe(true);
|
||||
});
|
||||
|
||||
it("should return true for feature with empty dependencies array", () => {
|
||||
const feature = createFeature("f1", { dependencies: [] });
|
||||
it('should return true for feature with empty dependencies array', () => {
|
||||
const feature = createFeature('f1', { dependencies: [] });
|
||||
const allFeatures = [feature];
|
||||
|
||||
expect(areDependenciesSatisfied(feature, allFeatures)).toBe(true);
|
||||
});
|
||||
|
||||
it("should return true when all dependencies are completed", () => {
|
||||
it('should return true when all dependencies are completed', () => {
|
||||
const allFeatures = [
|
||||
createFeature("f1", { status: "completed" }),
|
||||
createFeature("f2", { status: "completed" }),
|
||||
createFeature("f3", { status: "backlog", dependencies: ["f1", "f2"] }),
|
||||
createFeature('f1', { status: 'completed' }),
|
||||
createFeature('f2', { status: 'completed' }),
|
||||
createFeature('f3', { status: 'backlog', dependencies: ['f1', 'f2'] }),
|
||||
];
|
||||
|
||||
expect(areDependenciesSatisfied(allFeatures[2], allFeatures)).toBe(true);
|
||||
});
|
||||
|
||||
it("should return true when all dependencies are verified", () => {
|
||||
it('should return true when all dependencies are verified', () => {
|
||||
const allFeatures = [
|
||||
createFeature("f1", { status: "verified" }),
|
||||
createFeature("f2", { status: "verified" }),
|
||||
createFeature("f3", { status: "backlog", dependencies: ["f1", "f2"] }),
|
||||
createFeature('f1', { status: 'verified' }),
|
||||
createFeature('f2', { status: 'verified' }),
|
||||
createFeature('f3', { status: 'backlog', dependencies: ['f1', 'f2'] }),
|
||||
];
|
||||
|
||||
expect(areDependenciesSatisfied(allFeatures[2], allFeatures)).toBe(true);
|
||||
});
|
||||
|
||||
it("should return true when dependencies are mix of completed and verified", () => {
|
||||
it('should return true when dependencies are mix of completed and verified', () => {
|
||||
const allFeatures = [
|
||||
createFeature("f1", { status: "completed" }),
|
||||
createFeature("f2", { status: "verified" }),
|
||||
createFeature("f3", { status: "backlog", dependencies: ["f1", "f2"] }),
|
||||
createFeature('f1', { status: 'completed' }),
|
||||
createFeature('f2', { status: 'verified' }),
|
||||
createFeature('f3', { status: 'backlog', dependencies: ['f1', 'f2'] }),
|
||||
];
|
||||
|
||||
expect(areDependenciesSatisfied(allFeatures[2], allFeatures)).toBe(true);
|
||||
});
|
||||
|
||||
it("should return false when any dependency is in_progress", () => {
|
||||
it('should return false when any dependency is in_progress', () => {
|
||||
const allFeatures = [
|
||||
createFeature("f1", { status: "completed" }),
|
||||
createFeature("f2", { status: "in_progress" }),
|
||||
createFeature("f3", { status: "backlog", dependencies: ["f1", "f2"] }),
|
||||
createFeature('f1', { status: 'completed' }),
|
||||
createFeature('f2', { status: 'in_progress' }),
|
||||
createFeature('f3', { status: 'backlog', dependencies: ['f1', 'f2'] }),
|
||||
];
|
||||
|
||||
expect(areDependenciesSatisfied(allFeatures[2], allFeatures)).toBe(false);
|
||||
});
|
||||
|
||||
it("should return false when any dependency is in backlog", () => {
|
||||
it('should return false when any dependency is in backlog', () => {
|
||||
const allFeatures = [
|
||||
createFeature("f1", { status: "completed" }),
|
||||
createFeature("f2", { status: "backlog" }),
|
||||
createFeature("f3", { status: "backlog", dependencies: ["f1", "f2"] }),
|
||||
createFeature('f1', { status: 'completed' }),
|
||||
createFeature('f2', { status: 'backlog' }),
|
||||
createFeature('f3', { status: 'backlog', dependencies: ['f1', 'f2'] }),
|
||||
];
|
||||
|
||||
expect(areDependenciesSatisfied(allFeatures[2], allFeatures)).toBe(false);
|
||||
});
|
||||
|
||||
it("should return false when dependency is missing", () => {
|
||||
const allFeatures = [
|
||||
createFeature("f1", { status: "backlog", dependencies: ["missing"] }),
|
||||
];
|
||||
it('should return false when dependency is missing', () => {
|
||||
const allFeatures = [createFeature('f1', { status: 'backlog', dependencies: ['missing'] })];
|
||||
|
||||
expect(areDependenciesSatisfied(allFeatures[0], allFeatures)).toBe(false);
|
||||
});
|
||||
|
||||
it("should return false when multiple dependencies are incomplete", () => {
|
||||
it('should return false when multiple dependencies are incomplete', () => {
|
||||
const allFeatures = [
|
||||
createFeature("f1", { status: "backlog" }),
|
||||
createFeature("f2", { status: "in_progress" }),
|
||||
createFeature("f3", { status: "waiting_approval" }),
|
||||
createFeature("f4", { status: "backlog", dependencies: ["f1", "f2", "f3"] }),
|
||||
createFeature('f1', { status: 'backlog' }),
|
||||
createFeature('f2', { status: 'in_progress' }),
|
||||
createFeature('f3', { status: 'waiting_approval' }),
|
||||
createFeature('f4', { status: 'backlog', dependencies: ['f1', 'f2', 'f3'] }),
|
||||
];
|
||||
|
||||
expect(areDependenciesSatisfied(allFeatures[3], allFeatures)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getBlockingDependencies", () => {
|
||||
it("should return empty array for feature with no dependencies", () => {
|
||||
const feature = createFeature("f1");
|
||||
describe('getBlockingDependencies', () => {
|
||||
it('should return empty array for feature with no dependencies', () => {
|
||||
const feature = createFeature('f1');
|
||||
const allFeatures = [feature];
|
||||
|
||||
expect(getBlockingDependencies(feature, allFeatures)).toEqual([]);
|
||||
});
|
||||
|
||||
it("should return empty array for feature with empty dependencies array", () => {
|
||||
const feature = createFeature("f1", { dependencies: [] });
|
||||
it('should return empty array for feature with empty dependencies array', () => {
|
||||
const feature = createFeature('f1', { dependencies: [] });
|
||||
const allFeatures = [feature];
|
||||
|
||||
expect(getBlockingDependencies(feature, allFeatures)).toEqual([]);
|
||||
});
|
||||
|
||||
it("should return empty array when all dependencies are completed", () => {
|
||||
it('should return empty array when all dependencies are completed', () => {
|
||||
const allFeatures = [
|
||||
createFeature("f1", { status: "completed" }),
|
||||
createFeature("f2", { status: "completed" }),
|
||||
createFeature("f3", { status: "backlog", dependencies: ["f1", "f2"] }),
|
||||
createFeature('f1', { status: 'completed' }),
|
||||
createFeature('f2', { status: 'completed' }),
|
||||
createFeature('f3', { status: 'backlog', dependencies: ['f1', 'f2'] }),
|
||||
];
|
||||
|
||||
expect(getBlockingDependencies(allFeatures[2], allFeatures)).toEqual([]);
|
||||
});
|
||||
|
||||
it("should return empty array when all dependencies are verified", () => {
|
||||
it('should return empty array when all dependencies are verified', () => {
|
||||
const allFeatures = [
|
||||
createFeature("f1", { status: "verified" }),
|
||||
createFeature("f2", { status: "verified" }),
|
||||
createFeature("f3", { status: "backlog", dependencies: ["f1", "f2"] }),
|
||||
createFeature('f1', { status: 'verified' }),
|
||||
createFeature('f2', { status: 'verified' }),
|
||||
createFeature('f3', { status: 'backlog', dependencies: ['f1', 'f2'] }),
|
||||
];
|
||||
|
||||
expect(getBlockingDependencies(allFeatures[2], allFeatures)).toEqual([]);
|
||||
});
|
||||
|
||||
it("should return blocking dependencies in backlog status", () => {
|
||||
it('should return blocking dependencies in backlog status', () => {
|
||||
const allFeatures = [
|
||||
createFeature("f1", { status: "backlog" }),
|
||||
createFeature("f2", { status: "completed" }),
|
||||
createFeature("f3", { status: "backlog", dependencies: ["f1", "f2"] }),
|
||||
createFeature('f1', { status: 'backlog' }),
|
||||
createFeature('f2', { status: 'completed' }),
|
||||
createFeature('f3', { status: 'backlog', dependencies: ['f1', 'f2'] }),
|
||||
];
|
||||
|
||||
expect(getBlockingDependencies(allFeatures[2], allFeatures)).toEqual(["f1"]);
|
||||
expect(getBlockingDependencies(allFeatures[2], allFeatures)).toEqual(['f1']);
|
||||
});
|
||||
|
||||
it("should return blocking dependencies in in_progress status", () => {
|
||||
it('should return blocking dependencies in in_progress status', () => {
|
||||
const allFeatures = [
|
||||
createFeature("f1", { status: "in_progress" }),
|
||||
createFeature("f2", { status: "verified" }),
|
||||
createFeature("f3", { status: "backlog", dependencies: ["f1", "f2"] }),
|
||||
createFeature('f1', { status: 'in_progress' }),
|
||||
createFeature('f2', { status: 'verified' }),
|
||||
createFeature('f3', { status: 'backlog', dependencies: ['f1', 'f2'] }),
|
||||
];
|
||||
|
||||
expect(getBlockingDependencies(allFeatures[2], allFeatures)).toEqual(["f1"]);
|
||||
expect(getBlockingDependencies(allFeatures[2], allFeatures)).toEqual(['f1']);
|
||||
});
|
||||
|
||||
it("should return blocking dependencies in waiting_approval status", () => {
|
||||
it('should return blocking dependencies in waiting_approval status', () => {
|
||||
const allFeatures = [
|
||||
createFeature("f1", { status: "waiting_approval" }),
|
||||
createFeature("f2", { status: "completed" }),
|
||||
createFeature("f3", { status: "backlog", dependencies: ["f1", "f2"] }),
|
||||
createFeature('f1', { status: 'waiting_approval' }),
|
||||
createFeature('f2', { status: 'completed' }),
|
||||
createFeature('f3', { status: 'backlog', dependencies: ['f1', 'f2'] }),
|
||||
];
|
||||
|
||||
expect(getBlockingDependencies(allFeatures[2], allFeatures)).toEqual(["f1"]);
|
||||
expect(getBlockingDependencies(allFeatures[2], allFeatures)).toEqual(['f1']);
|
||||
});
|
||||
|
||||
it("should return all blocking dependencies", () => {
|
||||
it('should return all blocking dependencies', () => {
|
||||
const allFeatures = [
|
||||
createFeature("f1", { status: "backlog" }),
|
||||
createFeature("f2", { status: "in_progress" }),
|
||||
createFeature("f3", { status: "waiting_approval" }),
|
||||
createFeature("f4", { status: "completed" }),
|
||||
createFeature("f5", { status: "backlog", dependencies: ["f1", "f2", "f3", "f4"] }),
|
||||
createFeature('f1', { status: 'backlog' }),
|
||||
createFeature('f2', { status: 'in_progress' }),
|
||||
createFeature('f3', { status: 'waiting_approval' }),
|
||||
createFeature('f4', { status: 'completed' }),
|
||||
createFeature('f5', { status: 'backlog', dependencies: ['f1', 'f2', 'f3', 'f4'] }),
|
||||
];
|
||||
|
||||
const blocking = getBlockingDependencies(allFeatures[4], allFeatures);
|
||||
expect(blocking).toHaveLength(3);
|
||||
expect(blocking).toContain("f1");
|
||||
expect(blocking).toContain("f2");
|
||||
expect(blocking).toContain("f3");
|
||||
expect(blocking).not.toContain("f4");
|
||||
expect(blocking).toContain('f1');
|
||||
expect(blocking).toContain('f2');
|
||||
expect(blocking).toContain('f3');
|
||||
expect(blocking).not.toContain('f4');
|
||||
});
|
||||
|
||||
it("should handle missing dependencies", () => {
|
||||
const allFeatures = [
|
||||
createFeature("f1", { status: "backlog", dependencies: ["missing"] }),
|
||||
];
|
||||
it('should handle missing dependencies', () => {
|
||||
const allFeatures = [createFeature('f1', { status: 'backlog', dependencies: ['missing'] })];
|
||||
|
||||
// Missing dependencies won't be in the blocking list since they don't exist
|
||||
expect(getBlockingDependencies(allFeatures[0], allFeatures)).toEqual([]);
|
||||
});
|
||||
|
||||
it("should handle mix of completed, verified, and incomplete dependencies", () => {
|
||||
it('should handle mix of completed, verified, and incomplete dependencies', () => {
|
||||
const allFeatures = [
|
||||
createFeature("f1", { status: "completed" }),
|
||||
createFeature("f2", { status: "verified" }),
|
||||
createFeature("f3", { status: "in_progress" }),
|
||||
createFeature("f4", { status: "backlog" }),
|
||||
createFeature("f5", { status: "backlog", dependencies: ["f1", "f2", "f3", "f4"] }),
|
||||
createFeature('f1', { status: 'completed' }),
|
||||
createFeature('f2', { status: 'verified' }),
|
||||
createFeature('f3', { status: 'in_progress' }),
|
||||
createFeature('f4', { status: 'backlog' }),
|
||||
createFeature('f5', { status: 'backlog', dependencies: ['f1', 'f2', 'f3', 'f4'] }),
|
||||
];
|
||||
|
||||
const blocking = getBlockingDependencies(allFeatures[4], allFeatures);
|
||||
expect(blocking).toHaveLength(2);
|
||||
expect(blocking).toContain("f3");
|
||||
expect(blocking).toContain("f4");
|
||||
expect(blocking).toContain('f3');
|
||||
expect(blocking).toContain('f4');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,146 +1,211 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
isAbortError,
|
||||
isAuthenticationError,
|
||||
isCancellationError,
|
||||
classifyError,
|
||||
getUserFriendlyErrorMessage,
|
||||
type ErrorType,
|
||||
} from "@/lib/error-handler.js";
|
||||
} from '@automaker/utils';
|
||||
|
||||
describe("error-handler.ts", () => {
|
||||
describe("isAbortError", () => {
|
||||
it("should detect AbortError by error name", () => {
|
||||
const error = new Error("Operation cancelled");
|
||||
error.name = "AbortError";
|
||||
describe('error-handler.ts', () => {
|
||||
describe('isAbortError', () => {
|
||||
it('should detect AbortError by error name', () => {
|
||||
const error = new Error('Operation cancelled');
|
||||
error.name = 'AbortError';
|
||||
expect(isAbortError(error)).toBe(true);
|
||||
});
|
||||
|
||||
it("should detect abort error by message content", () => {
|
||||
const error = new Error("Request was aborted");
|
||||
it('should detect abort error by message content', () => {
|
||||
const error = new Error('Request was aborted');
|
||||
expect(isAbortError(error)).toBe(true);
|
||||
});
|
||||
|
||||
it("should return false for non-abort errors", () => {
|
||||
const error = new Error("Something else went wrong");
|
||||
it('should return false for non-abort errors', () => {
|
||||
const error = new Error('Something else went wrong');
|
||||
expect(isAbortError(error)).toBe(false);
|
||||
});
|
||||
|
||||
it("should return false for non-Error objects", () => {
|
||||
expect(isAbortError("not an error")).toBe(false);
|
||||
it('should return false for non-Error objects', () => {
|
||||
expect(isAbortError('not an error')).toBe(false);
|
||||
expect(isAbortError(null)).toBe(false);
|
||||
expect(isAbortError(undefined)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isAuthenticationError", () => {
|
||||
it("should detect 'Authentication failed' message", () => {
|
||||
expect(isAuthenticationError("Authentication failed")).toBe(true);
|
||||
describe('isCancellationError', () => {
|
||||
it("should detect 'cancelled' message", () => {
|
||||
expect(isCancellationError('Operation was cancelled')).toBe(true);
|
||||
});
|
||||
|
||||
it("should detect 'Invalid API key' message", () => {
|
||||
expect(isAuthenticationError("Invalid API key provided")).toBe(true);
|
||||
it("should detect 'canceled' message", () => {
|
||||
expect(isCancellationError('Request was canceled')).toBe(true);
|
||||
});
|
||||
|
||||
it("should detect 'authentication_failed' message", () => {
|
||||
expect(isAuthenticationError("authentication_failed")).toBe(true);
|
||||
it("should detect 'stopped' message", () => {
|
||||
expect(isCancellationError('Process was stopped')).toBe(true);
|
||||
});
|
||||
|
||||
it("should detect 'Fix external API key' message", () => {
|
||||
expect(isAuthenticationError("Fix external API key configuration")).toBe(true);
|
||||
it("should detect 'aborted' message", () => {
|
||||
expect(isCancellationError('Task was aborted')).toBe(true);
|
||||
});
|
||||
|
||||
it("should return false for non-authentication errors", () => {
|
||||
expect(isAuthenticationError("Network connection error")).toBe(false);
|
||||
expect(isAuthenticationError("File not found")).toBe(false);
|
||||
it('should be case insensitive', () => {
|
||||
expect(isCancellationError('CANCELLED')).toBe(true);
|
||||
expect(isCancellationError('Canceled')).toBe(true);
|
||||
});
|
||||
|
||||
it("should be case sensitive", () => {
|
||||
expect(isAuthenticationError("authentication Failed")).toBe(false);
|
||||
it('should return false for non-cancellation errors', () => {
|
||||
expect(isCancellationError('File not found')).toBe(false);
|
||||
expect(isCancellationError('Network error')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("classifyError", () => {
|
||||
it("should classify authentication errors", () => {
|
||||
const error = new Error("Authentication failed");
|
||||
describe('isAuthenticationError', () => {
|
||||
it("should detect 'Authentication failed' message", () => {
|
||||
expect(isAuthenticationError('Authentication failed')).toBe(true);
|
||||
});
|
||||
|
||||
it("should detect 'Invalid API key' message", () => {
|
||||
expect(isAuthenticationError('Invalid API key provided')).toBe(true);
|
||||
});
|
||||
|
||||
it("should detect 'authentication_failed' message", () => {
|
||||
expect(isAuthenticationError('authentication_failed')).toBe(true);
|
||||
});
|
||||
|
||||
it("should detect 'Fix external API key' message", () => {
|
||||
expect(isAuthenticationError('Fix external API key configuration')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for non-authentication errors', () => {
|
||||
expect(isAuthenticationError('Network connection error')).toBe(false);
|
||||
expect(isAuthenticationError('File not found')).toBe(false);
|
||||
});
|
||||
|
||||
it('should be case sensitive', () => {
|
||||
expect(isAuthenticationError('authentication Failed')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('classifyError', () => {
|
||||
it('should classify authentication errors', () => {
|
||||
const error = new Error('Authentication failed');
|
||||
const result = classifyError(error);
|
||||
|
||||
expect(result.type).toBe("authentication");
|
||||
expect(result.type).toBe('authentication');
|
||||
expect(result.isAuth).toBe(true);
|
||||
expect(result.isAbort).toBe(false);
|
||||
expect(result.message).toBe("Authentication failed");
|
||||
expect(result.message).toBe('Authentication failed');
|
||||
expect(result.originalError).toBe(error);
|
||||
});
|
||||
|
||||
it("should classify abort errors", () => {
|
||||
const error = new Error("Operation aborted");
|
||||
error.name = "AbortError";
|
||||
it('should classify abort errors', () => {
|
||||
const error = new Error('Operation aborted');
|
||||
error.name = 'AbortError';
|
||||
const result = classifyError(error);
|
||||
|
||||
expect(result.type).toBe("abort");
|
||||
expect(result.type).toBe('abort');
|
||||
expect(result.isAbort).toBe(true);
|
||||
expect(result.isAuth).toBe(false);
|
||||
expect(result.message).toBe("Operation aborted");
|
||||
expect(result.message).toBe('Operation aborted');
|
||||
});
|
||||
|
||||
it("should prioritize auth over abort if both match", () => {
|
||||
const error = new Error("Authentication failed and aborted");
|
||||
it('should prioritize auth over abort if both match', () => {
|
||||
const error = new Error('Authentication failed and aborted');
|
||||
const result = classifyError(error);
|
||||
|
||||
expect(result.type).toBe("authentication");
|
||||
expect(result.type).toBe('authentication');
|
||||
expect(result.isAuth).toBe(true);
|
||||
expect(result.isAbort).toBe(true); // Still detected as abort too
|
||||
});
|
||||
|
||||
it("should classify generic Error as execution error", () => {
|
||||
const error = new Error("Something went wrong");
|
||||
it('should classify cancellation errors', () => {
|
||||
const error = new Error('Operation was cancelled');
|
||||
const result = classifyError(error);
|
||||
|
||||
expect(result.type).toBe("execution");
|
||||
expect(result.type).toBe('cancellation');
|
||||
expect(result.isCancellation).toBe(true);
|
||||
expect(result.isAbort).toBe(false);
|
||||
expect(result.isAuth).toBe(false);
|
||||
});
|
||||
|
||||
it('should prioritize abort over cancellation if both match', () => {
|
||||
const error = new Error('Operation aborted');
|
||||
error.name = 'AbortError';
|
||||
const result = classifyError(error);
|
||||
|
||||
expect(result.type).toBe('abort');
|
||||
expect(result.isAbort).toBe(true);
|
||||
expect(result.isCancellation).toBe(true); // Still detected as cancellation too
|
||||
});
|
||||
|
||||
it("should classify cancellation errors with 'canceled' spelling", () => {
|
||||
const error = new Error('Request was canceled');
|
||||
const result = classifyError(error);
|
||||
|
||||
expect(result.type).toBe('cancellation');
|
||||
expect(result.isCancellation).toBe(true);
|
||||
});
|
||||
|
||||
it("should classify cancellation errors with 'stopped' message", () => {
|
||||
const error = new Error('Process was stopped');
|
||||
const result = classifyError(error);
|
||||
|
||||
expect(result.type).toBe('cancellation');
|
||||
expect(result.isCancellation).toBe(true);
|
||||
});
|
||||
|
||||
it('should classify generic Error as execution error', () => {
|
||||
const error = new Error('Something went wrong');
|
||||
const result = classifyError(error);
|
||||
|
||||
expect(result.type).toBe('execution');
|
||||
expect(result.isAuth).toBe(false);
|
||||
expect(result.isAbort).toBe(false);
|
||||
});
|
||||
|
||||
it("should classify non-Error objects as unknown", () => {
|
||||
const error = "string error";
|
||||
it('should classify non-Error objects as unknown', () => {
|
||||
const error = 'string error';
|
||||
const result = classifyError(error);
|
||||
|
||||
expect(result.type).toBe("unknown");
|
||||
expect(result.message).toBe("string error");
|
||||
expect(result.type).toBe('unknown');
|
||||
expect(result.message).toBe('string error');
|
||||
});
|
||||
|
||||
it("should handle null and undefined", () => {
|
||||
it('should handle null and undefined', () => {
|
||||
const nullResult = classifyError(null);
|
||||
expect(nullResult.type).toBe("unknown");
|
||||
expect(nullResult.message).toBe("Unknown error");
|
||||
expect(nullResult.type).toBe('unknown');
|
||||
expect(nullResult.message).toBe('Unknown error');
|
||||
|
||||
const undefinedResult = classifyError(undefined);
|
||||
expect(undefinedResult.type).toBe("unknown");
|
||||
expect(undefinedResult.message).toBe("Unknown error");
|
||||
expect(undefinedResult.type).toBe('unknown');
|
||||
expect(undefinedResult.message).toBe('Unknown error');
|
||||
});
|
||||
});
|
||||
|
||||
describe("getUserFriendlyErrorMessage", () => {
|
||||
it("should return friendly message for abort errors", () => {
|
||||
const error = new Error("abort");
|
||||
describe('getUserFriendlyErrorMessage', () => {
|
||||
it('should return friendly message for abort errors', () => {
|
||||
const error = new Error('abort');
|
||||
const result = getUserFriendlyErrorMessage(error);
|
||||
expect(result).toBe("Operation was cancelled");
|
||||
expect(result).toBe('Operation was cancelled');
|
||||
});
|
||||
|
||||
it("should return friendly message for authentication errors", () => {
|
||||
const error = new Error("Authentication failed");
|
||||
it('should return friendly message for authentication errors', () => {
|
||||
const error = new Error('Authentication failed');
|
||||
const result = getUserFriendlyErrorMessage(error);
|
||||
expect(result).toBe("Authentication failed. Please check your API key.");
|
||||
expect(result).toBe('Authentication failed. Please check your API key.');
|
||||
});
|
||||
|
||||
it("should return original message for other errors", () => {
|
||||
const error = new Error("File not found");
|
||||
it('should return original message for other errors', () => {
|
||||
const error = new Error('File not found');
|
||||
const result = getUserFriendlyErrorMessage(error);
|
||||
expect(result).toBe("File not found");
|
||||
expect(result).toBe('File not found');
|
||||
});
|
||||
|
||||
it("should handle non-Error objects", () => {
|
||||
const result = getUserFriendlyErrorMessage("Custom error");
|
||||
expect(result).toBe("Custom error");
|
||||
it('should handle non-Error objects', () => {
|
||||
const result = getUserFriendlyErrorMessage('Custom error');
|
||||
expect(result).toBe('Custom error');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { mkdirSafe, existsSafe } from "@/lib/fs-utils.js";
|
||||
import fs from "fs/promises";
|
||||
import path from "path";
|
||||
import os from "os";
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { mkdirSafe, existsSafe } from '@automaker/utils';
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
import os from 'os';
|
||||
|
||||
describe("fs-utils.ts", () => {
|
||||
describe('fs-utils.ts', () => {
|
||||
let testDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
@@ -22,43 +22,41 @@ describe("fs-utils.ts", () => {
|
||||
}
|
||||
});
|
||||
|
||||
describe("mkdirSafe", () => {
|
||||
it("should create a new directory", async () => {
|
||||
const newDir = path.join(testDir, "new-directory");
|
||||
describe('mkdirSafe', () => {
|
||||
it('should create a new directory', async () => {
|
||||
const newDir = path.join(testDir, 'new-directory');
|
||||
await mkdirSafe(newDir);
|
||||
|
||||
const stats = await fs.stat(newDir);
|
||||
expect(stats.isDirectory()).toBe(true);
|
||||
});
|
||||
|
||||
it("should succeed if directory already exists", async () => {
|
||||
const existingDir = path.join(testDir, "existing");
|
||||
it('should succeed if directory already exists', async () => {
|
||||
const existingDir = path.join(testDir, 'existing');
|
||||
await fs.mkdir(existingDir);
|
||||
|
||||
// Should not throw
|
||||
await expect(mkdirSafe(existingDir)).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("should create nested directories", async () => {
|
||||
const nestedDir = path.join(testDir, "a", "b", "c");
|
||||
it('should create nested directories', async () => {
|
||||
const nestedDir = path.join(testDir, 'a', 'b', 'c');
|
||||
await mkdirSafe(nestedDir);
|
||||
|
||||
const stats = await fs.stat(nestedDir);
|
||||
expect(stats.isDirectory()).toBe(true);
|
||||
});
|
||||
|
||||
it("should throw if path exists as a file", async () => {
|
||||
const filePath = path.join(testDir, "file.txt");
|
||||
await fs.writeFile(filePath, "content");
|
||||
it('should throw if path exists as a file', async () => {
|
||||
const filePath = path.join(testDir, 'file.txt');
|
||||
await fs.writeFile(filePath, 'content');
|
||||
|
||||
await expect(mkdirSafe(filePath)).rejects.toThrow(
|
||||
"Path exists and is not a directory"
|
||||
);
|
||||
await expect(mkdirSafe(filePath)).rejects.toThrow('Path exists and is not a directory');
|
||||
});
|
||||
|
||||
it("should succeed if path is a symlink to a directory", async () => {
|
||||
const realDir = path.join(testDir, "real-dir");
|
||||
const symlinkPath = path.join(testDir, "link-to-dir");
|
||||
it('should succeed if path is a symlink to a directory', async () => {
|
||||
const realDir = path.join(testDir, 'real-dir');
|
||||
const symlinkPath = path.join(testDir, 'link-to-dir');
|
||||
await fs.mkdir(realDir);
|
||||
await fs.symlink(realDir, symlinkPath);
|
||||
|
||||
@@ -66,12 +64,12 @@ describe("fs-utils.ts", () => {
|
||||
await expect(mkdirSafe(symlinkPath)).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("should handle ELOOP error gracefully when checking path", async () => {
|
||||
it('should handle ELOOP error gracefully when checking path', async () => {
|
||||
// Mock lstat to throw ELOOP error
|
||||
const originalLstat = fs.lstat;
|
||||
const mkdirSafePath = path.join(testDir, "eloop-path");
|
||||
|
||||
vi.spyOn(fs, "lstat").mockRejectedValueOnce({ code: "ELOOP" });
|
||||
const mkdirSafePath = path.join(testDir, 'eloop-path');
|
||||
|
||||
vi.spyOn(fs, 'lstat').mockRejectedValueOnce({ code: 'ELOOP' });
|
||||
|
||||
// Should not throw, should return gracefully
|
||||
await expect(mkdirSafe(mkdirSafePath)).resolves.toBeUndefined();
|
||||
@@ -79,13 +77,13 @@ describe("fs-utils.ts", () => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("should handle EEXIST error gracefully when creating directory", async () => {
|
||||
const newDir = path.join(testDir, "race-condition-dir");
|
||||
|
||||
it('should handle EEXIST error gracefully when creating directory', async () => {
|
||||
const newDir = path.join(testDir, 'race-condition-dir');
|
||||
|
||||
// Mock lstat to return ENOENT (path doesn't exist)
|
||||
// Then mock mkdir to throw EEXIST (race condition)
|
||||
vi.spyOn(fs, "lstat").mockRejectedValueOnce({ code: "ENOENT" });
|
||||
vi.spyOn(fs, "mkdir").mockRejectedValueOnce({ code: "EEXIST" });
|
||||
vi.spyOn(fs, 'lstat').mockRejectedValueOnce({ code: 'ENOENT' });
|
||||
vi.spyOn(fs, 'mkdir').mockRejectedValueOnce({ code: 'EEXIST' });
|
||||
|
||||
// Should not throw, should return gracefully
|
||||
await expect(mkdirSafe(newDir)).resolves.toBeUndefined();
|
||||
@@ -93,13 +91,13 @@ describe("fs-utils.ts", () => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("should handle ELOOP error gracefully when creating directory", async () => {
|
||||
const newDir = path.join(testDir, "eloop-create-dir");
|
||||
|
||||
it('should handle ELOOP error gracefully when creating directory', async () => {
|
||||
const newDir = path.join(testDir, 'eloop-create-dir');
|
||||
|
||||
// Mock lstat to return ENOENT (path doesn't exist)
|
||||
// Then mock mkdir to throw ELOOP
|
||||
vi.spyOn(fs, "lstat").mockRejectedValueOnce({ code: "ENOENT" });
|
||||
vi.spyOn(fs, "mkdir").mockRejectedValueOnce({ code: "ELOOP" });
|
||||
vi.spyOn(fs, 'lstat').mockRejectedValueOnce({ code: 'ENOENT' });
|
||||
vi.spyOn(fs, 'mkdir').mockRejectedValueOnce({ code: 'ELOOP' });
|
||||
|
||||
// Should not throw, should return gracefully
|
||||
await expect(mkdirSafe(newDir)).resolves.toBeUndefined();
|
||||
@@ -108,34 +106,34 @@ describe("fs-utils.ts", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("existsSafe", () => {
|
||||
it("should return true for existing file", async () => {
|
||||
const filePath = path.join(testDir, "test-file.txt");
|
||||
await fs.writeFile(filePath, "content");
|
||||
describe('existsSafe', () => {
|
||||
it('should return true for existing file', async () => {
|
||||
const filePath = path.join(testDir, 'test-file.txt');
|
||||
await fs.writeFile(filePath, 'content');
|
||||
|
||||
const exists = await existsSafe(filePath);
|
||||
expect(exists).toBe(true);
|
||||
});
|
||||
|
||||
it("should return true for existing directory", async () => {
|
||||
const dirPath = path.join(testDir, "test-dir");
|
||||
it('should return true for existing directory', async () => {
|
||||
const dirPath = path.join(testDir, 'test-dir');
|
||||
await fs.mkdir(dirPath);
|
||||
|
||||
const exists = await existsSafe(dirPath);
|
||||
expect(exists).toBe(true);
|
||||
});
|
||||
|
||||
it("should return false for non-existent path", async () => {
|
||||
const nonExistent = path.join(testDir, "does-not-exist");
|
||||
it('should return false for non-existent path', async () => {
|
||||
const nonExistent = path.join(testDir, 'does-not-exist');
|
||||
|
||||
const exists = await existsSafe(nonExistent);
|
||||
expect(exists).toBe(false);
|
||||
});
|
||||
|
||||
it("should return true for symlink", async () => {
|
||||
const realFile = path.join(testDir, "real-file.txt");
|
||||
const symlinkPath = path.join(testDir, "link-to-file");
|
||||
await fs.writeFile(realFile, "content");
|
||||
it('should return true for symlink', async () => {
|
||||
const realFile = path.join(testDir, 'real-file.txt');
|
||||
const symlinkPath = path.join(testDir, 'link-to-file');
|
||||
await fs.writeFile(realFile, 'content');
|
||||
await fs.symlink(realFile, symlinkPath);
|
||||
|
||||
const exists = await existsSafe(symlinkPath);
|
||||
@@ -143,29 +141,29 @@ describe("fs-utils.ts", () => {
|
||||
});
|
||||
|
||||
it("should return true for broken symlink (symlink exists even if target doesn't)", async () => {
|
||||
const symlinkPath = path.join(testDir, "broken-link");
|
||||
const nonExistent = path.join(testDir, "non-existent-target");
|
||||
const symlinkPath = path.join(testDir, 'broken-link');
|
||||
const nonExistent = path.join(testDir, 'non-existent-target');
|
||||
await fs.symlink(nonExistent, symlinkPath);
|
||||
|
||||
const exists = await existsSafe(symlinkPath);
|
||||
expect(exists).toBe(true);
|
||||
});
|
||||
|
||||
it("should return true for ELOOP error (symlink loop)", async () => {
|
||||
it('should return true for ELOOP error (symlink loop)', async () => {
|
||||
// Mock lstat to throw ELOOP error
|
||||
vi.spyOn(fs, "lstat").mockRejectedValueOnce({ code: "ELOOP" });
|
||||
vi.spyOn(fs, 'lstat').mockRejectedValueOnce({ code: 'ELOOP' });
|
||||
|
||||
const exists = await existsSafe("/some/path/with/loop");
|
||||
const exists = await existsSafe('/some/path/with/loop');
|
||||
expect(exists).toBe(true);
|
||||
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("should throw for other errors", async () => {
|
||||
it('should throw for other errors', async () => {
|
||||
// Mock lstat to throw a non-ENOENT, non-ELOOP error
|
||||
vi.spyOn(fs, "lstat").mockRejectedValueOnce({ code: "EACCES" });
|
||||
vi.spyOn(fs, 'lstat').mockRejectedValueOnce({ code: 'EACCES' });
|
||||
|
||||
await expect(existsSafe("/some/path")).rejects.toMatchObject({ code: "EACCES" });
|
||||
await expect(existsSafe('/some/path')).rejects.toMatchObject({ code: 'EACCES' });
|
||||
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
@@ -1,174 +1,164 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import {
|
||||
getMimeTypeForImage,
|
||||
readImageAsBase64,
|
||||
convertImagesToContentBlocks,
|
||||
formatImagePathsForPrompt,
|
||||
} from "@/lib/image-handler.js";
|
||||
import { pngBase64Fixture } from "../../fixtures/images.js";
|
||||
import * as fs from "fs/promises";
|
||||
} from '@automaker/utils';
|
||||
import { pngBase64Fixture } from '../../fixtures/images.js';
|
||||
import * as fs from 'fs/promises';
|
||||
|
||||
vi.mock("fs/promises");
|
||||
vi.mock('fs/promises');
|
||||
|
||||
describe("image-handler.ts", () => {
|
||||
describe('image-handler.ts', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("getMimeTypeForImage", () => {
|
||||
it("should return correct MIME type for .jpg", () => {
|
||||
expect(getMimeTypeForImage("test.jpg")).toBe("image/jpeg");
|
||||
expect(getMimeTypeForImage("/path/to/test.jpg")).toBe("image/jpeg");
|
||||
describe('getMimeTypeForImage', () => {
|
||||
it('should return correct MIME type for .jpg', () => {
|
||||
expect(getMimeTypeForImage('test.jpg')).toBe('image/jpeg');
|
||||
expect(getMimeTypeForImage('/path/to/test.jpg')).toBe('image/jpeg');
|
||||
});
|
||||
|
||||
it("should return correct MIME type for .jpeg", () => {
|
||||
expect(getMimeTypeForImage("test.jpeg")).toBe("image/jpeg");
|
||||
it('should return correct MIME type for .jpeg', () => {
|
||||
expect(getMimeTypeForImage('test.jpeg')).toBe('image/jpeg');
|
||||
});
|
||||
|
||||
it("should return correct MIME type for .png", () => {
|
||||
expect(getMimeTypeForImage("test.png")).toBe("image/png");
|
||||
it('should return correct MIME type for .png', () => {
|
||||
expect(getMimeTypeForImage('test.png')).toBe('image/png');
|
||||
});
|
||||
|
||||
it("should return correct MIME type for .gif", () => {
|
||||
expect(getMimeTypeForImage("test.gif")).toBe("image/gif");
|
||||
it('should return correct MIME type for .gif', () => {
|
||||
expect(getMimeTypeForImage('test.gif')).toBe('image/gif');
|
||||
});
|
||||
|
||||
it("should return correct MIME type for .webp", () => {
|
||||
expect(getMimeTypeForImage("test.webp")).toBe("image/webp");
|
||||
it('should return correct MIME type for .webp', () => {
|
||||
expect(getMimeTypeForImage('test.webp')).toBe('image/webp');
|
||||
});
|
||||
|
||||
it("should be case-insensitive", () => {
|
||||
expect(getMimeTypeForImage("test.PNG")).toBe("image/png");
|
||||
expect(getMimeTypeForImage("test.JPG")).toBe("image/jpeg");
|
||||
expect(getMimeTypeForImage("test.GIF")).toBe("image/gif");
|
||||
expect(getMimeTypeForImage("test.WEBP")).toBe("image/webp");
|
||||
it('should be case-insensitive', () => {
|
||||
expect(getMimeTypeForImage('test.PNG')).toBe('image/png');
|
||||
expect(getMimeTypeForImage('test.JPG')).toBe('image/jpeg');
|
||||
expect(getMimeTypeForImage('test.GIF')).toBe('image/gif');
|
||||
expect(getMimeTypeForImage('test.WEBP')).toBe('image/webp');
|
||||
});
|
||||
|
||||
it("should default to image/png for unknown extensions", () => {
|
||||
expect(getMimeTypeForImage("test.unknown")).toBe("image/png");
|
||||
expect(getMimeTypeForImage("test.txt")).toBe("image/png");
|
||||
expect(getMimeTypeForImage("test")).toBe("image/png");
|
||||
it('should default to image/png for unknown extensions', () => {
|
||||
expect(getMimeTypeForImage('test.unknown')).toBe('image/png');
|
||||
expect(getMimeTypeForImage('test.txt')).toBe('image/png');
|
||||
expect(getMimeTypeForImage('test')).toBe('image/png');
|
||||
});
|
||||
|
||||
it("should handle paths with multiple dots", () => {
|
||||
expect(getMimeTypeForImage("my.image.file.jpg")).toBe("image/jpeg");
|
||||
it('should handle paths with multiple dots', () => {
|
||||
expect(getMimeTypeForImage('my.image.file.jpg')).toBe('image/jpeg');
|
||||
});
|
||||
});
|
||||
|
||||
describe("readImageAsBase64", () => {
|
||||
it("should read image and return base64 data", async () => {
|
||||
const mockBuffer = Buffer.from(pngBase64Fixture, "base64");
|
||||
describe('readImageAsBase64', () => {
|
||||
it('should read image and return base64 data', async () => {
|
||||
const mockBuffer = Buffer.from(pngBase64Fixture, 'base64');
|
||||
vi.mocked(fs.readFile).mockResolvedValue(mockBuffer);
|
||||
|
||||
const result = await readImageAsBase64("/path/to/test.png");
|
||||
const result = await readImageAsBase64('/path/to/test.png');
|
||||
|
||||
expect(result).toMatchObject({
|
||||
base64: pngBase64Fixture,
|
||||
mimeType: "image/png",
|
||||
filename: "test.png",
|
||||
originalPath: "/path/to/test.png",
|
||||
mimeType: 'image/png',
|
||||
filename: 'test.png',
|
||||
originalPath: '/path/to/test.png',
|
||||
});
|
||||
expect(fs.readFile).toHaveBeenCalledWith("/path/to/test.png");
|
||||
expect(fs.readFile).toHaveBeenCalledWith('/path/to/test.png');
|
||||
});
|
||||
|
||||
it("should handle different image formats", async () => {
|
||||
const mockBuffer = Buffer.from("jpeg-data");
|
||||
it('should handle different image formats', async () => {
|
||||
const mockBuffer = Buffer.from('jpeg-data');
|
||||
vi.mocked(fs.readFile).mockResolvedValue(mockBuffer);
|
||||
|
||||
const result = await readImageAsBase64("/path/to/photo.jpg");
|
||||
const result = await readImageAsBase64('/path/to/photo.jpg');
|
||||
|
||||
expect(result.mimeType).toBe("image/jpeg");
|
||||
expect(result.filename).toBe("photo.jpg");
|
||||
expect(result.base64).toBe(mockBuffer.toString("base64"));
|
||||
expect(result.mimeType).toBe('image/jpeg');
|
||||
expect(result.filename).toBe('photo.jpg');
|
||||
expect(result.base64).toBe(mockBuffer.toString('base64'));
|
||||
});
|
||||
|
||||
it("should extract filename from path", async () => {
|
||||
const mockBuffer = Buffer.from("data");
|
||||
it('should extract filename from path', async () => {
|
||||
const mockBuffer = Buffer.from('data');
|
||||
vi.mocked(fs.readFile).mockResolvedValue(mockBuffer);
|
||||
|
||||
const result = await readImageAsBase64("/deep/nested/path/image.webp");
|
||||
const result = await readImageAsBase64('/deep/nested/path/image.webp');
|
||||
|
||||
expect(result.filename).toBe("image.webp");
|
||||
expect(result.filename).toBe('image.webp');
|
||||
});
|
||||
|
||||
it("should throw error if file cannot be read", async () => {
|
||||
vi.mocked(fs.readFile).mockRejectedValue(new Error("File not found"));
|
||||
it('should throw error if file cannot be read', async () => {
|
||||
vi.mocked(fs.readFile).mockRejectedValue(new Error('File not found'));
|
||||
|
||||
await expect(readImageAsBase64("/nonexistent.png")).rejects.toThrow(
|
||||
"File not found"
|
||||
);
|
||||
await expect(readImageAsBase64('/nonexistent.png')).rejects.toThrow('File not found');
|
||||
});
|
||||
});
|
||||
|
||||
describe("convertImagesToContentBlocks", () => {
|
||||
it("should convert single image to content block", async () => {
|
||||
const mockBuffer = Buffer.from(pngBase64Fixture, "base64");
|
||||
describe('convertImagesToContentBlocks', () => {
|
||||
it('should convert single image to content block', async () => {
|
||||
const mockBuffer = Buffer.from(pngBase64Fixture, 'base64');
|
||||
vi.mocked(fs.readFile).mockResolvedValue(mockBuffer);
|
||||
|
||||
const result = await convertImagesToContentBlocks(["/path/test.png"]);
|
||||
const result = await convertImagesToContentBlocks(['/path/test.png']);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toMatchObject({
|
||||
type: "image",
|
||||
type: 'image',
|
||||
source: {
|
||||
type: "base64",
|
||||
media_type: "image/png",
|
||||
type: 'base64',
|
||||
media_type: 'image/png',
|
||||
data: pngBase64Fixture,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("should convert multiple images to content blocks", async () => {
|
||||
const mockBuffer = Buffer.from("test-data");
|
||||
it('should convert multiple images to content blocks', async () => {
|
||||
const mockBuffer = Buffer.from('test-data');
|
||||
vi.mocked(fs.readFile).mockResolvedValue(mockBuffer);
|
||||
|
||||
const result = await convertImagesToContentBlocks([
|
||||
"/a.png",
|
||||
"/b.jpg",
|
||||
"/c.webp",
|
||||
]);
|
||||
const result = await convertImagesToContentBlocks(['/a.png', '/b.jpg', '/c.webp']);
|
||||
|
||||
expect(result).toHaveLength(3);
|
||||
expect(result[0].source.media_type).toBe("image/png");
|
||||
expect(result[1].source.media_type).toBe("image/jpeg");
|
||||
expect(result[2].source.media_type).toBe("image/webp");
|
||||
expect(result[0].source.media_type).toBe('image/png');
|
||||
expect(result[1].source.media_type).toBe('image/jpeg');
|
||||
expect(result[2].source.media_type).toBe('image/webp');
|
||||
});
|
||||
|
||||
it("should resolve relative paths with workDir", async () => {
|
||||
const mockBuffer = Buffer.from("data");
|
||||
it('should resolve relative paths with workDir', async () => {
|
||||
const mockBuffer = Buffer.from('data');
|
||||
vi.mocked(fs.readFile).mockResolvedValue(mockBuffer);
|
||||
|
||||
await convertImagesToContentBlocks(["relative.png"], "/work/dir");
|
||||
await convertImagesToContentBlocks(['relative.png'], '/work/dir');
|
||||
|
||||
// Use path-agnostic check since Windows uses backslashes
|
||||
const calls = vi.mocked(fs.readFile).mock.calls;
|
||||
expect(calls[0][0]).toMatch(/relative\.png$/);
|
||||
expect(calls[0][0]).toContain("work");
|
||||
expect(calls[0][0]).toContain("dir");
|
||||
expect(calls[0][0]).toContain('work');
|
||||
expect(calls[0][0]).toContain('dir');
|
||||
});
|
||||
|
||||
it("should handle absolute paths without workDir", async () => {
|
||||
const mockBuffer = Buffer.from("data");
|
||||
it('should handle absolute paths without workDir', async () => {
|
||||
const mockBuffer = Buffer.from('data');
|
||||
vi.mocked(fs.readFile).mockResolvedValue(mockBuffer);
|
||||
|
||||
await convertImagesToContentBlocks(["/absolute/path.png"]);
|
||||
await convertImagesToContentBlocks(['/absolute/path.png']);
|
||||
|
||||
expect(fs.readFile).toHaveBeenCalledWith("/absolute/path.png");
|
||||
expect(fs.readFile).toHaveBeenCalledWith('/absolute/path.png');
|
||||
});
|
||||
|
||||
it("should continue processing on individual image errors", async () => {
|
||||
it('should continue processing on individual image errors', async () => {
|
||||
vi.mocked(fs.readFile)
|
||||
.mockResolvedValueOnce(Buffer.from("ok1"))
|
||||
.mockRejectedValueOnce(new Error("Failed"))
|
||||
.mockResolvedValueOnce(Buffer.from("ok2"));
|
||||
.mockResolvedValueOnce(Buffer.from('ok1'))
|
||||
.mockRejectedValueOnce(new Error('Failed'))
|
||||
.mockResolvedValueOnce(Buffer.from('ok2'));
|
||||
|
||||
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
|
||||
const result = await convertImagesToContentBlocks([
|
||||
"/a.png",
|
||||
"/b.png",
|
||||
"/c.png",
|
||||
]);
|
||||
const result = await convertImagesToContentBlocks(['/a.png', '/b.png', '/c.png']);
|
||||
|
||||
expect(result).toHaveLength(2); // Only successful images
|
||||
expect(consoleSpy).toHaveBeenCalled();
|
||||
@@ -176,56 +166,52 @@ describe("image-handler.ts", () => {
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("should return empty array for empty input", async () => {
|
||||
it('should return empty array for empty input', async () => {
|
||||
const result = await convertImagesToContentBlocks([]);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("should handle undefined workDir", async () => {
|
||||
const mockBuffer = Buffer.from("data");
|
||||
it('should handle undefined workDir', async () => {
|
||||
const mockBuffer = Buffer.from('data');
|
||||
vi.mocked(fs.readFile).mockResolvedValue(mockBuffer);
|
||||
|
||||
const result = await convertImagesToContentBlocks(["/test.png"], undefined);
|
||||
const result = await convertImagesToContentBlocks(['/test.png'], undefined);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(fs.readFile).toHaveBeenCalledWith("/test.png");
|
||||
expect(fs.readFile).toHaveBeenCalledWith('/test.png');
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatImagePathsForPrompt", () => {
|
||||
it("should format single image path as bulleted list", () => {
|
||||
const result = formatImagePathsForPrompt(["/path/image.png"]);
|
||||
describe('formatImagePathsForPrompt', () => {
|
||||
it('should format single image path as bulleted list', () => {
|
||||
const result = formatImagePathsForPrompt(['/path/image.png']);
|
||||
|
||||
expect(result).toContain("\n\nAttached images:");
|
||||
expect(result).toContain("- /path/image.png");
|
||||
expect(result).toContain('\n\nAttached images:');
|
||||
expect(result).toContain('- /path/image.png');
|
||||
});
|
||||
|
||||
it("should format multiple image paths as bulleted list", () => {
|
||||
const result = formatImagePathsForPrompt([
|
||||
"/path/a.png",
|
||||
"/path/b.jpg",
|
||||
"/path/c.webp",
|
||||
]);
|
||||
it('should format multiple image paths as bulleted list', () => {
|
||||
const result = formatImagePathsForPrompt(['/path/a.png', '/path/b.jpg', '/path/c.webp']);
|
||||
|
||||
expect(result).toContain("Attached images:");
|
||||
expect(result).toContain("- /path/a.png");
|
||||
expect(result).toContain("- /path/b.jpg");
|
||||
expect(result).toContain("- /path/c.webp");
|
||||
expect(result).toContain('Attached images:');
|
||||
expect(result).toContain('- /path/a.png');
|
||||
expect(result).toContain('- /path/b.jpg');
|
||||
expect(result).toContain('- /path/c.webp');
|
||||
});
|
||||
|
||||
it("should return empty string for empty array", () => {
|
||||
it('should return empty string for empty array', () => {
|
||||
const result = formatImagePathsForPrompt([]);
|
||||
expect(result).toBe("");
|
||||
expect(result).toBe('');
|
||||
});
|
||||
|
||||
it("should start with double newline", () => {
|
||||
const result = formatImagePathsForPrompt(["/test.png"]);
|
||||
expect(result.startsWith("\n\n")).toBe(true);
|
||||
it('should start with double newline', () => {
|
||||
const result = formatImagePathsForPrompt(['/test.png']);
|
||||
expect(result.startsWith('\n\n')).toBe(true);
|
||||
});
|
||||
|
||||
it("should handle paths with special characters", () => {
|
||||
const result = formatImagePathsForPrompt(["/path/with spaces/image.png"]);
|
||||
expect(result).toContain("- /path/with spaces/image.png");
|
||||
it('should handle paths with special characters', () => {
|
||||
const result = formatImagePathsForPrompt(['/path/with spaces/image.png']);
|
||||
expect(result).toContain('- /path/with spaces/image.png');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,12 +1,7 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import {
|
||||
LogLevel,
|
||||
createLogger,
|
||||
getLogLevel,
|
||||
setLogLevel,
|
||||
} from "@/lib/logger.js";
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { LogLevel, createLogger, getLogLevel, setLogLevel } from '@automaker/utils';
|
||||
|
||||
describe("logger.ts", () => {
|
||||
describe('logger.ts', () => {
|
||||
let consoleSpy: {
|
||||
log: ReturnType<typeof vi.spyOn>;
|
||||
warn: ReturnType<typeof vi.spyOn>;
|
||||
@@ -17,9 +12,9 @@ describe("logger.ts", () => {
|
||||
beforeEach(() => {
|
||||
originalLogLevel = getLogLevel();
|
||||
consoleSpy = {
|
||||
log: vi.spyOn(console, "log").mockImplementation(() => {}),
|
||||
warn: vi.spyOn(console, "warn").mockImplementation(() => {}),
|
||||
error: vi.spyOn(console, "error").mockImplementation(() => {}),
|
||||
log: vi.spyOn(console, 'log').mockImplementation(() => {}),
|
||||
warn: vi.spyOn(console, 'warn').mockImplementation(() => {}),
|
||||
error: vi.spyOn(console, 'error').mockImplementation(() => {}),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -30,8 +25,8 @@ describe("logger.ts", () => {
|
||||
consoleSpy.error.mockRestore();
|
||||
});
|
||||
|
||||
describe("LogLevel enum", () => {
|
||||
it("should have correct numeric values", () => {
|
||||
describe('LogLevel enum', () => {
|
||||
it('should have correct numeric values', () => {
|
||||
expect(LogLevel.ERROR).toBe(0);
|
||||
expect(LogLevel.WARN).toBe(1);
|
||||
expect(LogLevel.INFO).toBe(2);
|
||||
@@ -39,8 +34,8 @@ describe("logger.ts", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("setLogLevel and getLogLevel", () => {
|
||||
it("should set and get log level", () => {
|
||||
describe('setLogLevel and getLogLevel', () => {
|
||||
it('should set and get log level', () => {
|
||||
setLogLevel(LogLevel.DEBUG);
|
||||
expect(getLogLevel()).toBe(LogLevel.DEBUG);
|
||||
|
||||
@@ -49,71 +44,66 @@ describe("logger.ts", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("createLogger", () => {
|
||||
it("should create a logger with context prefix", () => {
|
||||
describe('createLogger', () => {
|
||||
it('should create a logger with context prefix', () => {
|
||||
setLogLevel(LogLevel.INFO);
|
||||
const logger = createLogger("TestContext");
|
||||
const logger = createLogger('TestContext');
|
||||
|
||||
logger.info("test message");
|
||||
logger.info('test message');
|
||||
|
||||
expect(consoleSpy.log).toHaveBeenCalledWith("[TestContext]", "test message");
|
||||
expect(consoleSpy.log).toHaveBeenCalledWith('[TestContext]', 'test message');
|
||||
});
|
||||
|
||||
it("should log error at all log levels", () => {
|
||||
const logger = createLogger("Test");
|
||||
it('should log error at all log levels', () => {
|
||||
const logger = createLogger('Test');
|
||||
|
||||
setLogLevel(LogLevel.ERROR);
|
||||
logger.error("error message");
|
||||
expect(consoleSpy.error).toHaveBeenCalledWith("[Test]", "error message");
|
||||
logger.error('error message');
|
||||
expect(consoleSpy.error).toHaveBeenCalledWith('[Test]', 'error message');
|
||||
});
|
||||
|
||||
it("should log warn when level is WARN or higher", () => {
|
||||
const logger = createLogger("Test");
|
||||
it('should log warn when level is WARN or higher', () => {
|
||||
const logger = createLogger('Test');
|
||||
|
||||
setLogLevel(LogLevel.ERROR);
|
||||
logger.warn("warn message 1");
|
||||
logger.warn('warn message 1');
|
||||
expect(consoleSpy.warn).not.toHaveBeenCalled();
|
||||
|
||||
setLogLevel(LogLevel.WARN);
|
||||
logger.warn("warn message 2");
|
||||
expect(consoleSpy.warn).toHaveBeenCalledWith("[Test]", "warn message 2");
|
||||
logger.warn('warn message 2');
|
||||
expect(consoleSpy.warn).toHaveBeenCalledWith('[Test]', 'warn message 2');
|
||||
});
|
||||
|
||||
it("should log info when level is INFO or higher", () => {
|
||||
const logger = createLogger("Test");
|
||||
it('should log info when level is INFO or higher', () => {
|
||||
const logger = createLogger('Test');
|
||||
|
||||
setLogLevel(LogLevel.WARN);
|
||||
logger.info("info message 1");
|
||||
logger.info('info message 1');
|
||||
expect(consoleSpy.log).not.toHaveBeenCalled();
|
||||
|
||||
setLogLevel(LogLevel.INFO);
|
||||
logger.info("info message 2");
|
||||
expect(consoleSpy.log).toHaveBeenCalledWith("[Test]", "info message 2");
|
||||
logger.info('info message 2');
|
||||
expect(consoleSpy.log).toHaveBeenCalledWith('[Test]', 'info message 2');
|
||||
});
|
||||
|
||||
it("should log debug only when level is DEBUG", () => {
|
||||
const logger = createLogger("Test");
|
||||
it('should log debug only when level is DEBUG', () => {
|
||||
const logger = createLogger('Test');
|
||||
|
||||
setLogLevel(LogLevel.INFO);
|
||||
logger.debug("debug message 1");
|
||||
logger.debug('debug message 1');
|
||||
expect(consoleSpy.log).not.toHaveBeenCalled();
|
||||
|
||||
setLogLevel(LogLevel.DEBUG);
|
||||
logger.debug("debug message 2");
|
||||
expect(consoleSpy.log).toHaveBeenCalledWith("[Test]", "[DEBUG]", "debug message 2");
|
||||
logger.debug('debug message 2');
|
||||
expect(consoleSpy.log).toHaveBeenCalledWith('[Test]', '[DEBUG]', 'debug message 2');
|
||||
});
|
||||
|
||||
it("should pass multiple arguments to log functions", () => {
|
||||
it('should pass multiple arguments to log functions', () => {
|
||||
setLogLevel(LogLevel.DEBUG);
|
||||
const logger = createLogger("Multi");
|
||||
const logger = createLogger('Multi');
|
||||
|
||||
logger.info("message", { data: "value" }, 123);
|
||||
expect(consoleSpy.log).toHaveBeenCalledWith(
|
||||
"[Multi]",
|
||||
"message",
|
||||
{ data: "value" },
|
||||
123
|
||||
);
|
||||
logger.info('message', { data: 'value' }, 123);
|
||||
expect(consoleSpy.log).toHaveBeenCalledWith('[Multi]', 'message', { data: 'value' }, 123);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import {
|
||||
resolveModelString,
|
||||
getEffectiveModel,
|
||||
CLAUDE_MODEL_MAP,
|
||||
DEFAULT_MODELS,
|
||||
} from "@/lib/model-resolver.js";
|
||||
} from '@automaker/model-resolver';
|
||||
|
||||
describe("model-resolver.ts", () => {
|
||||
describe('model-resolver.ts', () => {
|
||||
let consoleSpy: any;
|
||||
|
||||
beforeEach(() => {
|
||||
consoleSpy = {
|
||||
log: vi.spyOn(console, "log").mockImplementation(() => {}),
|
||||
warn: vi.spyOn(console, "warn").mockImplementation(() => {}),
|
||||
log: vi.spyOn(console, 'log').mockImplementation(() => {}),
|
||||
warn: vi.spyOn(console, 'warn').mockImplementation(() => {}),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -21,27 +21,27 @@ describe("model-resolver.ts", () => {
|
||||
consoleSpy.warn.mockRestore();
|
||||
});
|
||||
|
||||
describe("resolveModelString", () => {
|
||||
describe('resolveModelString', () => {
|
||||
it("should resolve 'haiku' alias to full model string", () => {
|
||||
const result = resolveModelString("haiku");
|
||||
expect(result).toBe("claude-haiku-4-5");
|
||||
const result = resolveModelString('haiku');
|
||||
expect(result).toBe('claude-haiku-4-5');
|
||||
});
|
||||
|
||||
it("should resolve 'sonnet' alias to full model string", () => {
|
||||
const result = resolveModelString("sonnet");
|
||||
expect(result).toBe("claude-sonnet-4-20250514");
|
||||
const result = resolveModelString('sonnet');
|
||||
expect(result).toBe('claude-sonnet-4-20250514');
|
||||
});
|
||||
|
||||
it("should resolve 'opus' alias to full model string", () => {
|
||||
const result = resolveModelString("opus");
|
||||
expect(result).toBe("claude-opus-4-5-20251101");
|
||||
const result = resolveModelString('opus');
|
||||
expect(result).toBe('claude-opus-4-5-20251101');
|
||||
expect(consoleSpy.log).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Resolved model alias: "opus"')
|
||||
);
|
||||
});
|
||||
|
||||
it("should treat unknown models as falling back to default", () => {
|
||||
const models = ["o1", "o1-mini", "o3", "gpt-5.2", "unknown-model"];
|
||||
it('should treat unknown models as falling back to default', () => {
|
||||
const models = ['o1', 'o1-mini', 'o3', 'gpt-5.2', 'unknown-model'];
|
||||
models.forEach((model) => {
|
||||
const result = resolveModelString(model);
|
||||
// Should fall back to default since these aren't supported
|
||||
@@ -49,95 +49,91 @@ describe("model-resolver.ts", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("should pass through full Claude model strings", () => {
|
||||
const models = [
|
||||
"claude-opus-4-5-20251101",
|
||||
"claude-sonnet-4-20250514",
|
||||
"claude-haiku-4-5",
|
||||
];
|
||||
it('should pass through full Claude model strings', () => {
|
||||
const models = ['claude-opus-4-5-20251101', 'claude-sonnet-4-20250514', 'claude-haiku-4-5'];
|
||||
models.forEach((model) => {
|
||||
const result = resolveModelString(model);
|
||||
expect(result).toBe(model);
|
||||
});
|
||||
expect(consoleSpy.log).toHaveBeenCalledWith(
|
||||
expect.stringContaining("Using full Claude model string")
|
||||
expect.stringContaining('Using full Claude model string')
|
||||
);
|
||||
});
|
||||
|
||||
it("should return default model when modelKey is undefined", () => {
|
||||
it('should return default model when modelKey is undefined', () => {
|
||||
const result = resolveModelString(undefined);
|
||||
expect(result).toBe(DEFAULT_MODELS.claude);
|
||||
});
|
||||
|
||||
it("should return custom default model when provided", () => {
|
||||
const customDefault = "custom-model";
|
||||
it('should return custom default model when provided', () => {
|
||||
const customDefault = 'custom-model';
|
||||
const result = resolveModelString(undefined, customDefault);
|
||||
expect(result).toBe(customDefault);
|
||||
});
|
||||
|
||||
it("should return default for unknown model key", () => {
|
||||
const result = resolveModelString("unknown-model");
|
||||
it('should return default for unknown model key', () => {
|
||||
const result = resolveModelString('unknown-model');
|
||||
expect(result).toBe(DEFAULT_MODELS.claude);
|
||||
expect(consoleSpy.warn).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Unknown model key "unknown-model"')
|
||||
);
|
||||
});
|
||||
|
||||
it("should handle empty string", () => {
|
||||
const result = resolveModelString("");
|
||||
it('should handle empty string', () => {
|
||||
const result = resolveModelString('');
|
||||
expect(result).toBe(DEFAULT_MODELS.claude);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getEffectiveModel", () => {
|
||||
it("should prioritize explicit model over session and default", () => {
|
||||
const result = getEffectiveModel("opus", "haiku", "gpt-5.2");
|
||||
expect(result).toBe("claude-opus-4-5-20251101");
|
||||
describe('getEffectiveModel', () => {
|
||||
it('should prioritize explicit model over session and default', () => {
|
||||
const result = getEffectiveModel('opus', 'haiku', 'gpt-5.2');
|
||||
expect(result).toBe('claude-opus-4-5-20251101');
|
||||
});
|
||||
|
||||
it("should use session model when explicit is not provided", () => {
|
||||
const result = getEffectiveModel(undefined, "sonnet", "gpt-5.2");
|
||||
expect(result).toBe("claude-sonnet-4-20250514");
|
||||
it('should use session model when explicit is not provided', () => {
|
||||
const result = getEffectiveModel(undefined, 'sonnet', 'gpt-5.2');
|
||||
expect(result).toBe('claude-sonnet-4-20250514');
|
||||
});
|
||||
|
||||
it("should use default when neither explicit nor session is provided", () => {
|
||||
const customDefault = "claude-haiku-4-5";
|
||||
it('should use default when neither explicit nor session is provided', () => {
|
||||
const customDefault = 'claude-haiku-4-5';
|
||||
const result = getEffectiveModel(undefined, undefined, customDefault);
|
||||
expect(result).toBe(customDefault);
|
||||
});
|
||||
|
||||
it("should use Claude default when no arguments provided", () => {
|
||||
it('should use Claude default when no arguments provided', () => {
|
||||
const result = getEffectiveModel();
|
||||
expect(result).toBe(DEFAULT_MODELS.claude);
|
||||
});
|
||||
|
||||
it("should handle explicit empty strings as undefined", () => {
|
||||
const result = getEffectiveModel("", "haiku");
|
||||
expect(result).toBe("claude-haiku-4-5");
|
||||
it('should handle explicit empty strings as undefined', () => {
|
||||
const result = getEffectiveModel('', 'haiku');
|
||||
expect(result).toBe('claude-haiku-4-5');
|
||||
});
|
||||
});
|
||||
|
||||
describe("CLAUDE_MODEL_MAP", () => {
|
||||
it("should have haiku, sonnet, opus mappings", () => {
|
||||
expect(CLAUDE_MODEL_MAP).toHaveProperty("haiku");
|
||||
expect(CLAUDE_MODEL_MAP).toHaveProperty("sonnet");
|
||||
expect(CLAUDE_MODEL_MAP).toHaveProperty("opus");
|
||||
describe('CLAUDE_MODEL_MAP', () => {
|
||||
it('should have haiku, sonnet, opus mappings', () => {
|
||||
expect(CLAUDE_MODEL_MAP).toHaveProperty('haiku');
|
||||
expect(CLAUDE_MODEL_MAP).toHaveProperty('sonnet');
|
||||
expect(CLAUDE_MODEL_MAP).toHaveProperty('opus');
|
||||
});
|
||||
|
||||
it("should have valid Claude model strings", () => {
|
||||
expect(CLAUDE_MODEL_MAP.haiku).toContain("haiku");
|
||||
expect(CLAUDE_MODEL_MAP.sonnet).toContain("sonnet");
|
||||
expect(CLAUDE_MODEL_MAP.opus).toContain("opus");
|
||||
it('should have valid Claude model strings', () => {
|
||||
expect(CLAUDE_MODEL_MAP.haiku).toContain('haiku');
|
||||
expect(CLAUDE_MODEL_MAP.sonnet).toContain('sonnet');
|
||||
expect(CLAUDE_MODEL_MAP.opus).toContain('opus');
|
||||
});
|
||||
});
|
||||
|
||||
describe("DEFAULT_MODELS", () => {
|
||||
it("should have claude default", () => {
|
||||
expect(DEFAULT_MODELS).toHaveProperty("claude");
|
||||
describe('DEFAULT_MODELS', () => {
|
||||
it('should have claude default', () => {
|
||||
expect(DEFAULT_MODELS).toHaveProperty('claude');
|
||||
});
|
||||
|
||||
it("should have valid default model", () => {
|
||||
expect(DEFAULT_MODELS.claude).toContain("claude");
|
||||
it('should have valid default model', () => {
|
||||
expect(DEFAULT_MODELS.claude).toContain('claude');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,197 +1,120 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { buildPromptWithImages } from "@/lib/prompt-builder.js";
|
||||
import * as imageHandler from "@/lib/image-handler.js";
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import * as utils from '@automaker/utils';
|
||||
import * as fs from 'fs/promises';
|
||||
|
||||
vi.mock("@/lib/image-handler.js");
|
||||
// Mock fs module for the image-handler's readFile calls
|
||||
vi.mock('fs/promises');
|
||||
|
||||
describe("prompt-builder.ts", () => {
|
||||
describe('prompt-builder.ts', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
// Setup default mock for fs.readFile to return a valid image buffer
|
||||
vi.mocked(fs.readFile).mockResolvedValue(Buffer.from('fake-image-data'));
|
||||
});
|
||||
|
||||
describe("buildPromptWithImages", () => {
|
||||
it("should return plain text when no images provided", async () => {
|
||||
const result = await buildPromptWithImages("Hello world");
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('buildPromptWithImages', () => {
|
||||
it('should return plain text when no images provided', async () => {
|
||||
const result = await utils.buildPromptWithImages('Hello world');
|
||||
|
||||
expect(result).toEqual({
|
||||
content: "Hello world",
|
||||
content: 'Hello world',
|
||||
hasImages: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("should return plain text when imagePaths is empty array", async () => {
|
||||
const result = await buildPromptWithImages("Hello world", []);
|
||||
it('should return plain text when imagePaths is empty array', async () => {
|
||||
const result = await utils.buildPromptWithImages('Hello world', []);
|
||||
|
||||
expect(result).toEqual({
|
||||
content: "Hello world",
|
||||
content: 'Hello world',
|
||||
hasImages: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("should build content blocks with single image", async () => {
|
||||
vi.mocked(imageHandler.convertImagesToContentBlocks).mockResolvedValue([
|
||||
{
|
||||
type: "image",
|
||||
source: { type: "base64", media_type: "image/png", data: "base64data" },
|
||||
},
|
||||
]);
|
||||
|
||||
const result = await buildPromptWithImages("Describe this image", [
|
||||
"/test.png",
|
||||
]);
|
||||
it('should build content blocks with single image', async () => {
|
||||
const result = await utils.buildPromptWithImages('Describe this image', ['/test.png']);
|
||||
|
||||
expect(result.hasImages).toBe(true);
|
||||
expect(Array.isArray(result.content)).toBe(true);
|
||||
const content = result.content as Array<any>;
|
||||
const content = result.content as Array<{ type: string; text?: string }>;
|
||||
expect(content).toHaveLength(2);
|
||||
expect(content[0]).toEqual({ type: "text", text: "Describe this image" });
|
||||
expect(content[1].type).toBe("image");
|
||||
expect(content[0]).toEqual({ type: 'text', text: 'Describe this image' });
|
||||
expect(content[1].type).toBe('image');
|
||||
});
|
||||
|
||||
it("should build content blocks with multiple images", async () => {
|
||||
vi.mocked(imageHandler.convertImagesToContentBlocks).mockResolvedValue([
|
||||
{
|
||||
type: "image",
|
||||
source: { type: "base64", media_type: "image/png", data: "data1" },
|
||||
},
|
||||
{
|
||||
type: "image",
|
||||
source: { type: "base64", media_type: "image/jpeg", data: "data2" },
|
||||
},
|
||||
]);
|
||||
|
||||
const result = await buildPromptWithImages("Analyze these", [
|
||||
"/a.png",
|
||||
"/b.jpg",
|
||||
]);
|
||||
it('should build content blocks with multiple images', async () => {
|
||||
const result = await utils.buildPromptWithImages('Analyze these', ['/a.png', '/b.jpg']);
|
||||
|
||||
expect(result.hasImages).toBe(true);
|
||||
const content = result.content as Array<any>;
|
||||
const content = result.content as Array<{ type: string }>;
|
||||
expect(content).toHaveLength(3); // 1 text + 2 images
|
||||
expect(content[0].type).toBe("text");
|
||||
expect(content[1].type).toBe("image");
|
||||
expect(content[2].type).toBe("image");
|
||||
expect(content[0].type).toBe('text');
|
||||
expect(content[1].type).toBe('image');
|
||||
expect(content[2].type).toBe('image');
|
||||
});
|
||||
|
||||
it("should include image paths in text when requested", async () => {
|
||||
vi.mocked(imageHandler.formatImagePathsForPrompt).mockReturnValue(
|
||||
"\n\nAttached images:\n- /test.png"
|
||||
);
|
||||
vi.mocked(imageHandler.convertImagesToContentBlocks).mockResolvedValue([
|
||||
{
|
||||
type: "image",
|
||||
source: { type: "base64", media_type: "image/png", data: "data" },
|
||||
},
|
||||
]);
|
||||
|
||||
const result = await buildPromptWithImages(
|
||||
"Base prompt",
|
||||
["/test.png"],
|
||||
it('should include image paths in text when requested', async () => {
|
||||
const result = await utils.buildPromptWithImages(
|
||||
'Base prompt',
|
||||
['/test.png'],
|
||||
undefined,
|
||||
true
|
||||
);
|
||||
|
||||
expect(imageHandler.formatImagePathsForPrompt).toHaveBeenCalledWith([
|
||||
"/test.png",
|
||||
]);
|
||||
const content = result.content as Array<any>;
|
||||
expect(content[0].text).toContain("Base prompt");
|
||||
expect(content[0].text).toContain("Attached images:");
|
||||
const content = result.content as Array<{ type: string; text?: string }>;
|
||||
expect(content[0].text).toContain('Base prompt');
|
||||
expect(content[0].text).toContain('/test.png');
|
||||
});
|
||||
|
||||
it("should not include image paths by default", async () => {
|
||||
vi.mocked(imageHandler.formatImagePathsForPrompt).mockReturnValue(
|
||||
"\n\nAttached images:\n- /test.png"
|
||||
);
|
||||
vi.mocked(imageHandler.convertImagesToContentBlocks).mockResolvedValue([
|
||||
{
|
||||
type: "image",
|
||||
source: { type: "base64", media_type: "image/png", data: "data" },
|
||||
},
|
||||
]);
|
||||
it('should not include image paths by default', async () => {
|
||||
const result = await utils.buildPromptWithImages('Base prompt', ['/test.png']);
|
||||
|
||||
const result = await buildPromptWithImages("Base prompt", ["/test.png"]);
|
||||
|
||||
expect(imageHandler.formatImagePathsForPrompt).not.toHaveBeenCalled();
|
||||
const content = result.content as Array<any>;
|
||||
expect(content[0].text).toBe("Base prompt");
|
||||
const content = result.content as Array<{ type: string; text?: string }>;
|
||||
expect(content[0].text).toBe('Base prompt');
|
||||
expect(content[0].text).not.toContain('Attached');
|
||||
});
|
||||
|
||||
it("should pass workDir to convertImagesToContentBlocks", async () => {
|
||||
vi.mocked(imageHandler.convertImagesToContentBlocks).mockResolvedValue([
|
||||
{
|
||||
type: "image",
|
||||
source: { type: "base64", media_type: "image/png", data: "data" },
|
||||
},
|
||||
]);
|
||||
|
||||
await buildPromptWithImages("Test", ["/test.png"], "/work/dir");
|
||||
|
||||
expect(imageHandler.convertImagesToContentBlocks).toHaveBeenCalledWith(
|
||||
["/test.png"],
|
||||
"/work/dir"
|
||||
);
|
||||
});
|
||||
|
||||
it("should handle empty text content", async () => {
|
||||
vi.mocked(imageHandler.convertImagesToContentBlocks).mockResolvedValue([
|
||||
{
|
||||
type: "image",
|
||||
source: { type: "base64", media_type: "image/png", data: "data" },
|
||||
},
|
||||
]);
|
||||
|
||||
const result = await buildPromptWithImages("", ["/test.png"]);
|
||||
it('should handle empty text content', async () => {
|
||||
const result = await utils.buildPromptWithImages('', ['/test.png']);
|
||||
|
||||
expect(result.hasImages).toBe(true);
|
||||
// When text is empty/whitespace, should only have image blocks
|
||||
const content = result.content as Array<any>;
|
||||
expect(content.every((block) => block.type === "image")).toBe(true);
|
||||
const content = result.content as Array<{ type: string }>;
|
||||
expect(content.every((block) => block.type === 'image')).toBe(true);
|
||||
});
|
||||
|
||||
it("should trim text content before checking if empty", async () => {
|
||||
vi.mocked(imageHandler.convertImagesToContentBlocks).mockResolvedValue([
|
||||
{
|
||||
type: "image",
|
||||
source: { type: "base64", media_type: "image/png", data: "data" },
|
||||
},
|
||||
]);
|
||||
it('should trim text content before checking if empty', async () => {
|
||||
const result = await utils.buildPromptWithImages(' ', ['/test.png']);
|
||||
|
||||
const result = await buildPromptWithImages(" ", ["/test.png"]);
|
||||
|
||||
const content = result.content as Array<any>;
|
||||
const content = result.content as Array<{ type: string }>;
|
||||
// Whitespace-only text should be excluded
|
||||
expect(content.every((block) => block.type === "image")).toBe(true);
|
||||
expect(content.every((block) => block.type === 'image')).toBe(true);
|
||||
});
|
||||
|
||||
it("should return text when only one block and it's text", async () => {
|
||||
vi.mocked(imageHandler.convertImagesToContentBlocks).mockResolvedValue([]);
|
||||
// Make readFile reject to simulate image load failure
|
||||
vi.mocked(fs.readFile).mockRejectedValue(new Error('File not found'));
|
||||
|
||||
const result = await buildPromptWithImages("Just text", ["/missing.png"]);
|
||||
const result = await utils.buildPromptWithImages('Just text', ['/missing.png']);
|
||||
|
||||
// If no images are successfully loaded, should return just the text
|
||||
expect(result.content).toBe("Just text");
|
||||
expect(result.content).toBe('Just text');
|
||||
expect(result.hasImages).toBe(true); // Still true because images were requested
|
||||
});
|
||||
|
||||
it("should handle workDir with relative paths", async () => {
|
||||
vi.mocked(imageHandler.convertImagesToContentBlocks).mockResolvedValue([
|
||||
{
|
||||
type: "image",
|
||||
source: { type: "base64", media_type: "image/png", data: "data" },
|
||||
},
|
||||
]);
|
||||
it('should pass workDir for path resolution', async () => {
|
||||
// The function should use workDir to resolve relative paths
|
||||
const result = await utils.buildPromptWithImages('Test', ['relative.png'], '/work/dir');
|
||||
|
||||
await buildPromptWithImages(
|
||||
"Test",
|
||||
["relative.png"],
|
||||
"/absolute/work/dir"
|
||||
);
|
||||
|
||||
expect(imageHandler.convertImagesToContentBlocks).toHaveBeenCalledWith(
|
||||
["relative.png"],
|
||||
"/absolute/work/dir"
|
||||
);
|
||||
// Verify it tried to read the file (with resolved path including workDir)
|
||||
expect(fs.readFile).toHaveBeenCalled();
|
||||
// The path should be resolved using workDir
|
||||
const readCall = vi.mocked(fs.readFile).mock.calls[0][0];
|
||||
expect(readCall).toContain('relative.png');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
|
||||
describe("sdk-options.ts", () => {
|
||||
describe('sdk-options.ts', () => {
|
||||
let originalEnv: NodeJS.ProcessEnv;
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -12,34 +12,34 @@ describe("sdk-options.ts", () => {
|
||||
process.env = originalEnv;
|
||||
});
|
||||
|
||||
describe("TOOL_PRESETS", () => {
|
||||
it("should export readOnly tools", async () => {
|
||||
const { TOOL_PRESETS } = await import("@/lib/sdk-options.js");
|
||||
expect(TOOL_PRESETS.readOnly).toEqual(["Read", "Glob", "Grep"]);
|
||||
describe('TOOL_PRESETS', () => {
|
||||
it('should export readOnly tools', async () => {
|
||||
const { TOOL_PRESETS } = await import('@/lib/sdk-options.js');
|
||||
expect(TOOL_PRESETS.readOnly).toEqual(['Read', 'Glob', 'Grep']);
|
||||
});
|
||||
|
||||
it("should export specGeneration tools", async () => {
|
||||
const { TOOL_PRESETS } = await import("@/lib/sdk-options.js");
|
||||
expect(TOOL_PRESETS.specGeneration).toEqual(["Read", "Glob", "Grep"]);
|
||||
it('should export specGeneration tools', async () => {
|
||||
const { TOOL_PRESETS } = await import('@/lib/sdk-options.js');
|
||||
expect(TOOL_PRESETS.specGeneration).toEqual(['Read', 'Glob', 'Grep']);
|
||||
});
|
||||
|
||||
it("should export fullAccess tools", async () => {
|
||||
const { TOOL_PRESETS } = await import("@/lib/sdk-options.js");
|
||||
expect(TOOL_PRESETS.fullAccess).toContain("Read");
|
||||
expect(TOOL_PRESETS.fullAccess).toContain("Write");
|
||||
expect(TOOL_PRESETS.fullAccess).toContain("Edit");
|
||||
expect(TOOL_PRESETS.fullAccess).toContain("Bash");
|
||||
it('should export fullAccess tools', async () => {
|
||||
const { TOOL_PRESETS } = await import('@/lib/sdk-options.js');
|
||||
expect(TOOL_PRESETS.fullAccess).toContain('Read');
|
||||
expect(TOOL_PRESETS.fullAccess).toContain('Write');
|
||||
expect(TOOL_PRESETS.fullAccess).toContain('Edit');
|
||||
expect(TOOL_PRESETS.fullAccess).toContain('Bash');
|
||||
});
|
||||
|
||||
it("should export chat tools matching fullAccess", async () => {
|
||||
const { TOOL_PRESETS } = await import("@/lib/sdk-options.js");
|
||||
it('should export chat tools matching fullAccess', async () => {
|
||||
const { TOOL_PRESETS } = await import('@/lib/sdk-options.js');
|
||||
expect(TOOL_PRESETS.chat).toEqual(TOOL_PRESETS.fullAccess);
|
||||
});
|
||||
});
|
||||
|
||||
describe("MAX_TURNS", () => {
|
||||
it("should export turn presets", async () => {
|
||||
const { MAX_TURNS } = await import("@/lib/sdk-options.js");
|
||||
describe('MAX_TURNS', () => {
|
||||
it('should export turn presets', async () => {
|
||||
const { MAX_TURNS } = await import('@/lib/sdk-options.js');
|
||||
expect(MAX_TURNS.quick).toBe(50);
|
||||
expect(MAX_TURNS.standard).toBe(100);
|
||||
expect(MAX_TURNS.extended).toBe(250);
|
||||
@@ -47,71 +47,67 @@ describe("sdk-options.ts", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("getModelForUseCase", () => {
|
||||
it("should return explicit model when provided", async () => {
|
||||
const { getModelForUseCase } = await import("@/lib/sdk-options.js");
|
||||
const result = getModelForUseCase("spec", "claude-sonnet-4-20250514");
|
||||
expect(result).toBe("claude-sonnet-4-20250514");
|
||||
describe('getModelForUseCase', () => {
|
||||
it('should return explicit model when provided', async () => {
|
||||
const { getModelForUseCase } = await import('@/lib/sdk-options.js');
|
||||
const result = getModelForUseCase('spec', 'claude-sonnet-4-20250514');
|
||||
expect(result).toBe('claude-sonnet-4-20250514');
|
||||
});
|
||||
|
||||
it("should use environment variable for spec model", async () => {
|
||||
process.env.AUTOMAKER_MODEL_SPEC = "claude-sonnet-4-20250514";
|
||||
const { getModelForUseCase } = await import("@/lib/sdk-options.js");
|
||||
const result = getModelForUseCase("spec");
|
||||
expect(result).toBe("claude-sonnet-4-20250514");
|
||||
it('should use environment variable for spec model', async () => {
|
||||
process.env.AUTOMAKER_MODEL_SPEC = 'claude-sonnet-4-20250514';
|
||||
const { getModelForUseCase } = await import('@/lib/sdk-options.js');
|
||||
const result = getModelForUseCase('spec');
|
||||
expect(result).toBe('claude-sonnet-4-20250514');
|
||||
});
|
||||
|
||||
it("should use default model for spec when no override", async () => {
|
||||
it('should use default model for spec when no override', async () => {
|
||||
delete process.env.AUTOMAKER_MODEL_SPEC;
|
||||
delete process.env.AUTOMAKER_MODEL_DEFAULT;
|
||||
const { getModelForUseCase } = await import("@/lib/sdk-options.js");
|
||||
const result = getModelForUseCase("spec");
|
||||
expect(result).toContain("claude");
|
||||
const { getModelForUseCase } = await import('@/lib/sdk-options.js');
|
||||
const result = getModelForUseCase('spec');
|
||||
expect(result).toContain('claude');
|
||||
});
|
||||
|
||||
it("should fall back to AUTOMAKER_MODEL_DEFAULT", async () => {
|
||||
it('should fall back to AUTOMAKER_MODEL_DEFAULT', async () => {
|
||||
delete process.env.AUTOMAKER_MODEL_SPEC;
|
||||
process.env.AUTOMAKER_MODEL_DEFAULT = "claude-sonnet-4-20250514";
|
||||
const { getModelForUseCase } = await import("@/lib/sdk-options.js");
|
||||
const result = getModelForUseCase("spec");
|
||||
expect(result).toBe("claude-sonnet-4-20250514");
|
||||
process.env.AUTOMAKER_MODEL_DEFAULT = 'claude-sonnet-4-20250514';
|
||||
const { getModelForUseCase } = await import('@/lib/sdk-options.js');
|
||||
const result = getModelForUseCase('spec');
|
||||
expect(result).toBe('claude-sonnet-4-20250514');
|
||||
});
|
||||
});
|
||||
|
||||
describe("createSpecGenerationOptions", () => {
|
||||
it("should create options with spec generation settings", async () => {
|
||||
describe('createSpecGenerationOptions', () => {
|
||||
it('should create options with spec generation settings', async () => {
|
||||
const { createSpecGenerationOptions, TOOL_PRESETS, MAX_TURNS } =
|
||||
await import("@/lib/sdk-options.js");
|
||||
await import('@/lib/sdk-options.js');
|
||||
|
||||
const options = createSpecGenerationOptions({ cwd: "/test/path" });
|
||||
const options = createSpecGenerationOptions({ cwd: '/test/path' });
|
||||
|
||||
expect(options.cwd).toBe("/test/path");
|
||||
expect(options.cwd).toBe('/test/path');
|
||||
expect(options.maxTurns).toBe(MAX_TURNS.maximum);
|
||||
expect(options.allowedTools).toEqual([...TOOL_PRESETS.specGeneration]);
|
||||
expect(options.permissionMode).toBe("default");
|
||||
expect(options.permissionMode).toBe('default');
|
||||
});
|
||||
|
||||
it("should include system prompt when provided", async () => {
|
||||
const { createSpecGenerationOptions } = await import(
|
||||
"@/lib/sdk-options.js"
|
||||
);
|
||||
it('should include system prompt when provided', async () => {
|
||||
const { createSpecGenerationOptions } = await import('@/lib/sdk-options.js');
|
||||
|
||||
const options = createSpecGenerationOptions({
|
||||
cwd: "/test/path",
|
||||
systemPrompt: "Custom prompt",
|
||||
cwd: '/test/path',
|
||||
systemPrompt: 'Custom prompt',
|
||||
});
|
||||
|
||||
expect(options.systemPrompt).toBe("Custom prompt");
|
||||
expect(options.systemPrompt).toBe('Custom prompt');
|
||||
});
|
||||
|
||||
it("should include abort controller when provided", async () => {
|
||||
const { createSpecGenerationOptions } = await import(
|
||||
"@/lib/sdk-options.js"
|
||||
);
|
||||
it('should include abort controller when provided', async () => {
|
||||
const { createSpecGenerationOptions } = await import('@/lib/sdk-options.js');
|
||||
|
||||
const abortController = new AbortController();
|
||||
const options = createSpecGenerationOptions({
|
||||
cwd: "/test/path",
|
||||
cwd: '/test/path',
|
||||
abortController,
|
||||
});
|
||||
|
||||
@@ -119,42 +115,73 @@ describe("sdk-options.ts", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("createFeatureGenerationOptions", () => {
|
||||
it("should create options with feature generation settings", async () => {
|
||||
describe('createFeatureGenerationOptions', () => {
|
||||
it('should create options with feature generation settings', async () => {
|
||||
const { createFeatureGenerationOptions, TOOL_PRESETS, MAX_TURNS } =
|
||||
await import("@/lib/sdk-options.js");
|
||||
await import('@/lib/sdk-options.js');
|
||||
|
||||
const options = createFeatureGenerationOptions({ cwd: "/test/path" });
|
||||
const options = createFeatureGenerationOptions({ cwd: '/test/path' });
|
||||
|
||||
expect(options.cwd).toBe("/test/path");
|
||||
expect(options.cwd).toBe('/test/path');
|
||||
expect(options.maxTurns).toBe(MAX_TURNS.quick);
|
||||
expect(options.allowedTools).toEqual([...TOOL_PRESETS.readOnly]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createSuggestionsOptions", () => {
|
||||
it("should create options with suggestions settings", async () => {
|
||||
const { createSuggestionsOptions, TOOL_PRESETS, MAX_TURNS } = await import(
|
||||
"@/lib/sdk-options.js"
|
||||
);
|
||||
describe('createSuggestionsOptions', () => {
|
||||
it('should create options with suggestions settings', async () => {
|
||||
const { createSuggestionsOptions, TOOL_PRESETS, MAX_TURNS } =
|
||||
await import('@/lib/sdk-options.js');
|
||||
|
||||
const options = createSuggestionsOptions({ cwd: "/test/path" });
|
||||
const options = createSuggestionsOptions({ cwd: '/test/path' });
|
||||
|
||||
expect(options.cwd).toBe("/test/path");
|
||||
expect(options.cwd).toBe('/test/path');
|
||||
expect(options.maxTurns).toBe(MAX_TURNS.extended);
|
||||
expect(options.allowedTools).toEqual([...TOOL_PRESETS.readOnly]);
|
||||
});
|
||||
|
||||
it('should include systemPrompt when provided', async () => {
|
||||
const { createSuggestionsOptions } = await import('@/lib/sdk-options.js');
|
||||
|
||||
const options = createSuggestionsOptions({
|
||||
cwd: '/test/path',
|
||||
systemPrompt: 'Custom prompt',
|
||||
});
|
||||
|
||||
expect(options.systemPrompt).toBe('Custom prompt');
|
||||
});
|
||||
|
||||
it('should include abortController when provided', async () => {
|
||||
const { createSuggestionsOptions } = await import('@/lib/sdk-options.js');
|
||||
|
||||
const abortController = new AbortController();
|
||||
const options = createSuggestionsOptions({
|
||||
cwd: '/test/path',
|
||||
abortController,
|
||||
});
|
||||
|
||||
expect(options.abortController).toBe(abortController);
|
||||
});
|
||||
|
||||
it('should include outputFormat when provided', async () => {
|
||||
const { createSuggestionsOptions } = await import('@/lib/sdk-options.js');
|
||||
|
||||
const options = createSuggestionsOptions({
|
||||
cwd: '/test/path',
|
||||
outputFormat: { type: 'json' },
|
||||
});
|
||||
|
||||
expect(options.outputFormat).toEqual({ type: 'json' });
|
||||
});
|
||||
});
|
||||
|
||||
describe("createChatOptions", () => {
|
||||
it("should create options with chat settings", async () => {
|
||||
const { createChatOptions, TOOL_PRESETS, MAX_TURNS } = await import(
|
||||
"@/lib/sdk-options.js"
|
||||
);
|
||||
describe('createChatOptions', () => {
|
||||
it('should create options with chat settings', async () => {
|
||||
const { createChatOptions, TOOL_PRESETS, MAX_TURNS } = await import('@/lib/sdk-options.js');
|
||||
|
||||
const options = createChatOptions({ cwd: "/test/path" });
|
||||
const options = createChatOptions({ cwd: '/test/path' });
|
||||
|
||||
expect(options.cwd).toBe("/test/path");
|
||||
expect(options.cwd).toBe('/test/path');
|
||||
expect(options.maxTurns).toBe(MAX_TURNS.standard);
|
||||
expect(options.allowedTools).toEqual([...TOOL_PRESETS.chat]);
|
||||
expect(options.sandbox).toEqual({
|
||||
@@ -163,41 +190,38 @@ describe("sdk-options.ts", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("should prefer explicit model over session model", async () => {
|
||||
const { createChatOptions, getModelForUseCase } = await import(
|
||||
"@/lib/sdk-options.js"
|
||||
);
|
||||
it('should prefer explicit model over session model', async () => {
|
||||
const { createChatOptions, getModelForUseCase } = await import('@/lib/sdk-options.js');
|
||||
|
||||
const options = createChatOptions({
|
||||
cwd: "/test/path",
|
||||
model: "claude-opus-4-20250514",
|
||||
sessionModel: "claude-haiku-3-5-20241022",
|
||||
cwd: '/test/path',
|
||||
model: 'claude-opus-4-20250514',
|
||||
sessionModel: 'claude-haiku-3-5-20241022',
|
||||
});
|
||||
|
||||
expect(options.model).toBe("claude-opus-4-20250514");
|
||||
expect(options.model).toBe('claude-opus-4-20250514');
|
||||
});
|
||||
|
||||
it("should use session model when explicit model not provided", async () => {
|
||||
const { createChatOptions } = await import("@/lib/sdk-options.js");
|
||||
it('should use session model when explicit model not provided', async () => {
|
||||
const { createChatOptions } = await import('@/lib/sdk-options.js');
|
||||
|
||||
const options = createChatOptions({
|
||||
cwd: "/test/path",
|
||||
sessionModel: "claude-sonnet-4-20250514",
|
||||
cwd: '/test/path',
|
||||
sessionModel: 'claude-sonnet-4-20250514',
|
||||
});
|
||||
|
||||
expect(options.model).toBe("claude-sonnet-4-20250514");
|
||||
expect(options.model).toBe('claude-sonnet-4-20250514');
|
||||
});
|
||||
});
|
||||
|
||||
describe("createAutoModeOptions", () => {
|
||||
it("should create options with auto mode settings", async () => {
|
||||
const { createAutoModeOptions, TOOL_PRESETS, MAX_TURNS } = await import(
|
||||
"@/lib/sdk-options.js"
|
||||
);
|
||||
describe('createAutoModeOptions', () => {
|
||||
it('should create options with auto mode settings', async () => {
|
||||
const { createAutoModeOptions, TOOL_PRESETS, MAX_TURNS } =
|
||||
await import('@/lib/sdk-options.js');
|
||||
|
||||
const options = createAutoModeOptions({ cwd: "/test/path" });
|
||||
const options = createAutoModeOptions({ cwd: '/test/path' });
|
||||
|
||||
expect(options.cwd).toBe("/test/path");
|
||||
expect(options.cwd).toBe('/test/path');
|
||||
expect(options.maxTurns).toBe(MAX_TURNS.maximum);
|
||||
expect(options.allowedTools).toEqual([...TOOL_PRESETS.fullAccess]);
|
||||
expect(options.sandbox).toEqual({
|
||||
@@ -205,34 +229,92 @@ describe("sdk-options.ts", () => {
|
||||
autoAllowBashIfSandboxed: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('should include systemPrompt when provided', async () => {
|
||||
const { createAutoModeOptions } = await import('@/lib/sdk-options.js');
|
||||
|
||||
const options = createAutoModeOptions({
|
||||
cwd: '/test/path',
|
||||
systemPrompt: 'Custom prompt',
|
||||
});
|
||||
|
||||
expect(options.systemPrompt).toBe('Custom prompt');
|
||||
});
|
||||
|
||||
it('should include abortController when provided', async () => {
|
||||
const { createAutoModeOptions } = await import('@/lib/sdk-options.js');
|
||||
|
||||
const abortController = new AbortController();
|
||||
const options = createAutoModeOptions({
|
||||
cwd: '/test/path',
|
||||
abortController,
|
||||
});
|
||||
|
||||
expect(options.abortController).toBe(abortController);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createCustomOptions", () => {
|
||||
it("should create options with custom settings", async () => {
|
||||
const { createCustomOptions } = await import("@/lib/sdk-options.js");
|
||||
describe('createCustomOptions', () => {
|
||||
it('should create options with custom settings', async () => {
|
||||
const { createCustomOptions } = await import('@/lib/sdk-options.js');
|
||||
|
||||
const options = createCustomOptions({
|
||||
cwd: "/test/path",
|
||||
cwd: '/test/path',
|
||||
maxTurns: 10,
|
||||
allowedTools: ["Read", "Write"],
|
||||
allowedTools: ['Read', 'Write'],
|
||||
sandbox: { enabled: true },
|
||||
});
|
||||
|
||||
expect(options.cwd).toBe("/test/path");
|
||||
expect(options.cwd).toBe('/test/path');
|
||||
expect(options.maxTurns).toBe(10);
|
||||
expect(options.allowedTools).toEqual(["Read", "Write"]);
|
||||
expect(options.allowedTools).toEqual(['Read', 'Write']);
|
||||
expect(options.sandbox).toEqual({ enabled: true });
|
||||
});
|
||||
|
||||
it("should use defaults when optional params not provided", async () => {
|
||||
const { createCustomOptions, TOOL_PRESETS, MAX_TURNS } = await import(
|
||||
"@/lib/sdk-options.js"
|
||||
);
|
||||
it('should use defaults when optional params not provided', async () => {
|
||||
const { createCustomOptions, TOOL_PRESETS, MAX_TURNS } = await import('@/lib/sdk-options.js');
|
||||
|
||||
const options = createCustomOptions({ cwd: "/test/path" });
|
||||
const options = createCustomOptions({ cwd: '/test/path' });
|
||||
|
||||
expect(options.maxTurns).toBe(MAX_TURNS.maximum);
|
||||
expect(options.allowedTools).toEqual([...TOOL_PRESETS.readOnly]);
|
||||
});
|
||||
|
||||
it('should include sandbox when provided', async () => {
|
||||
const { createCustomOptions } = await import('@/lib/sdk-options.js');
|
||||
|
||||
const options = createCustomOptions({
|
||||
cwd: '/test/path',
|
||||
sandbox: { enabled: true, autoAllowBashIfSandboxed: false },
|
||||
});
|
||||
|
||||
expect(options.sandbox).toEqual({
|
||||
enabled: true,
|
||||
autoAllowBashIfSandboxed: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('should include systemPrompt when provided', async () => {
|
||||
const { createCustomOptions } = await import('@/lib/sdk-options.js');
|
||||
|
||||
const options = createCustomOptions({
|
||||
cwd: '/test/path',
|
||||
systemPrompt: 'Custom prompt',
|
||||
});
|
||||
|
||||
expect(options.systemPrompt).toBe('Custom prompt');
|
||||
});
|
||||
|
||||
it('should include abortController when provided', async () => {
|
||||
const { createCustomOptions } = await import('@/lib/sdk-options.js');
|
||||
|
||||
const abortController = new AbortController();
|
||||
const options = createCustomOptions({
|
||||
cwd: '/test/path',
|
||||
abortController,
|
||||
});
|
||||
|
||||
expect(options.abortController).toBe(abortController);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,207 +1,186 @@
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import path from "path";
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import path from 'path';
|
||||
|
||||
/**
|
||||
* Note: security.ts maintains module-level state (allowed paths Set).
|
||||
* We need to reset modules and reimport for each test to get fresh state.
|
||||
*/
|
||||
describe("security.ts", () => {
|
||||
describe('security.ts', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
describe("initAllowedPaths", () => {
|
||||
it("should parse comma-separated directories from environment", async () => {
|
||||
process.env.ALLOWED_PROJECT_DIRS = "/path1,/path2,/path3";
|
||||
process.env.DATA_DIR = "";
|
||||
describe('initAllowedPaths', () => {
|
||||
it('should load ALLOWED_ROOT_DIRECTORY if set', async () => {
|
||||
process.env.ALLOWED_ROOT_DIRECTORY = '/projects';
|
||||
delete process.env.DATA_DIR;
|
||||
|
||||
const { initAllowedPaths, getAllowedPaths } = await import(
|
||||
"@/lib/security.js"
|
||||
);
|
||||
const { initAllowedPaths, getAllowedPaths } = await import('@automaker/platform');
|
||||
initAllowedPaths();
|
||||
|
||||
const allowed = getAllowedPaths();
|
||||
expect(allowed).toContain(path.resolve("/path1"));
|
||||
expect(allowed).toContain(path.resolve("/path2"));
|
||||
expect(allowed).toContain(path.resolve("/path3"));
|
||||
expect(allowed).toContain(path.resolve('/projects'));
|
||||
});
|
||||
|
||||
it("should trim whitespace from paths", async () => {
|
||||
process.env.ALLOWED_PROJECT_DIRS = " /path1 , /path2 , /path3 ";
|
||||
process.env.DATA_DIR = "";
|
||||
it('should include DATA_DIR if set', async () => {
|
||||
delete process.env.ALLOWED_ROOT_DIRECTORY;
|
||||
process.env.DATA_DIR = '/data/dir';
|
||||
|
||||
const { initAllowedPaths, getAllowedPaths } = await import(
|
||||
"@/lib/security.js"
|
||||
);
|
||||
const { initAllowedPaths, getAllowedPaths } = await import('@automaker/platform');
|
||||
initAllowedPaths();
|
||||
|
||||
const allowed = getAllowedPaths();
|
||||
expect(allowed).toContain(path.resolve("/path1"));
|
||||
expect(allowed).toContain(path.resolve("/path2"));
|
||||
expect(allowed).toContain(path.resolve('/data/dir'));
|
||||
});
|
||||
|
||||
it("should always include DATA_DIR if set", async () => {
|
||||
process.env.ALLOWED_PROJECT_DIRS = "";
|
||||
process.env.DATA_DIR = "/data/dir";
|
||||
it('should include both ALLOWED_ROOT_DIRECTORY and DATA_DIR if both set', async () => {
|
||||
process.env.ALLOWED_ROOT_DIRECTORY = '/projects';
|
||||
process.env.DATA_DIR = '/data';
|
||||
|
||||
const { initAllowedPaths, getAllowedPaths } = await import(
|
||||
"@/lib/security.js"
|
||||
);
|
||||
const { initAllowedPaths, getAllowedPaths } = await import('@automaker/platform');
|
||||
initAllowedPaths();
|
||||
|
||||
const allowed = getAllowedPaths();
|
||||
expect(allowed).toContain(path.resolve("/data/dir"));
|
||||
expect(allowed).toContain(path.resolve('/projects'));
|
||||
expect(allowed).toContain(path.resolve('/data'));
|
||||
expect(allowed).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("should handle empty ALLOWED_PROJECT_DIRS", async () => {
|
||||
process.env.ALLOWED_PROJECT_DIRS = "";
|
||||
process.env.DATA_DIR = "/data";
|
||||
it('should return empty array when no paths configured', async () => {
|
||||
delete process.env.ALLOWED_ROOT_DIRECTORY;
|
||||
delete process.env.DATA_DIR;
|
||||
|
||||
const { initAllowedPaths, getAllowedPaths } = await import(
|
||||
"@/lib/security.js"
|
||||
);
|
||||
const { initAllowedPaths, getAllowedPaths } = await import('@automaker/platform');
|
||||
initAllowedPaths();
|
||||
|
||||
const allowed = getAllowedPaths();
|
||||
expect(allowed).toHaveLength(1);
|
||||
expect(allowed[0]).toBe(path.resolve("/data"));
|
||||
});
|
||||
|
||||
it("should skip empty entries in comma list", async () => {
|
||||
process.env.ALLOWED_PROJECT_DIRS = "/path1,,/path2, ,/path3";
|
||||
process.env.DATA_DIR = "";
|
||||
|
||||
const { initAllowedPaths, getAllowedPaths } = await import(
|
||||
"@/lib/security.js"
|
||||
);
|
||||
initAllowedPaths();
|
||||
|
||||
const allowed = getAllowedPaths();
|
||||
expect(allowed).toHaveLength(3);
|
||||
expect(allowed).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("addAllowedPath", () => {
|
||||
it("should add path to allowed list", async () => {
|
||||
process.env.ALLOWED_PROJECT_DIRS = "";
|
||||
process.env.DATA_DIR = "";
|
||||
describe('isPathAllowed', () => {
|
||||
it('should allow paths within ALLOWED_ROOT_DIRECTORY', async () => {
|
||||
process.env.ALLOWED_ROOT_DIRECTORY = '/allowed/project';
|
||||
process.env.DATA_DIR = '';
|
||||
|
||||
const { initAllowedPaths, addAllowedPath, getAllowedPaths } =
|
||||
await import("@/lib/security.js");
|
||||
const { initAllowedPaths, isPathAllowed } = await import('@automaker/platform');
|
||||
initAllowedPaths();
|
||||
|
||||
addAllowedPath("/new/path");
|
||||
// Paths within allowed directory should be allowed
|
||||
expect(isPathAllowed('/allowed/project/file.txt')).toBe(true);
|
||||
expect(isPathAllowed('/allowed/project/subdir/file.txt')).toBe(true);
|
||||
|
||||
const allowed = getAllowedPaths();
|
||||
expect(allowed).toContain(path.resolve("/new/path"));
|
||||
// Paths outside allowed directory should be denied
|
||||
expect(isPathAllowed('/not/allowed/file.txt')).toBe(false);
|
||||
expect(isPathAllowed('/tmp/file.txt')).toBe(false);
|
||||
expect(isPathAllowed('/etc/passwd')).toBe(false);
|
||||
});
|
||||
|
||||
it("should resolve relative paths before adding", async () => {
|
||||
process.env.ALLOWED_PROJECT_DIRS = "";
|
||||
process.env.DATA_DIR = "";
|
||||
it('should allow all paths when no restrictions are configured', async () => {
|
||||
delete process.env.DATA_DIR;
|
||||
delete process.env.ALLOWED_ROOT_DIRECTORY;
|
||||
|
||||
const { initAllowedPaths, addAllowedPath, getAllowedPaths } =
|
||||
await import("@/lib/security.js");
|
||||
const { initAllowedPaths, isPathAllowed } = await import('@automaker/platform');
|
||||
initAllowedPaths();
|
||||
|
||||
addAllowedPath("./relative/path");
|
||||
// All paths should be allowed when no restrictions are configured
|
||||
expect(isPathAllowed('/allowed/project/file.txt')).toBe(true);
|
||||
expect(isPathAllowed('/not/allowed/file.txt')).toBe(true);
|
||||
expect(isPathAllowed('/tmp/file.txt')).toBe(true);
|
||||
expect(isPathAllowed('/etc/passwd')).toBe(true);
|
||||
expect(isPathAllowed('/any/path')).toBe(true);
|
||||
});
|
||||
|
||||
const allowed = getAllowedPaths();
|
||||
it('should allow all paths when DATA_DIR is set but ALLOWED_ROOT_DIRECTORY is not', async () => {
|
||||
process.env.DATA_DIR = '/data';
|
||||
delete process.env.ALLOWED_ROOT_DIRECTORY;
|
||||
|
||||
const { initAllowedPaths, isPathAllowed } = await import('@automaker/platform');
|
||||
initAllowedPaths();
|
||||
|
||||
// DATA_DIR should be allowed
|
||||
expect(isPathAllowed('/data/settings.json')).toBe(true);
|
||||
// But all other paths should also be allowed when ALLOWED_ROOT_DIRECTORY is not set
|
||||
expect(isPathAllowed('/allowed/project/file.txt')).toBe(true);
|
||||
expect(isPathAllowed('/not/allowed/file.txt')).toBe(true);
|
||||
expect(isPathAllowed('/tmp/file.txt')).toBe(true);
|
||||
expect(isPathAllowed('/etc/passwd')).toBe(true);
|
||||
expect(isPathAllowed('/any/path')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validatePath', () => {
|
||||
it('should return resolved path for allowed paths', async () => {
|
||||
process.env.ALLOWED_ROOT_DIRECTORY = '/allowed';
|
||||
process.env.DATA_DIR = '';
|
||||
|
||||
const { initAllowedPaths, validatePath } = await import('@automaker/platform');
|
||||
initAllowedPaths();
|
||||
|
||||
const result = validatePath('/allowed/file.txt');
|
||||
expect(result).toBe(path.resolve('/allowed/file.txt'));
|
||||
});
|
||||
|
||||
it('should throw error for paths outside allowed directories', async () => {
|
||||
process.env.ALLOWED_ROOT_DIRECTORY = '/allowed';
|
||||
process.env.DATA_DIR = '';
|
||||
|
||||
const { initAllowedPaths, validatePath } = await import('@automaker/platform');
|
||||
initAllowedPaths();
|
||||
|
||||
// Disallowed paths should throw PathNotAllowedError
|
||||
expect(() => validatePath('/disallowed/file.txt')).toThrow();
|
||||
});
|
||||
|
||||
it('should not throw error for any path when no restrictions are configured', async () => {
|
||||
delete process.env.DATA_DIR;
|
||||
delete process.env.ALLOWED_ROOT_DIRECTORY;
|
||||
|
||||
const { initAllowedPaths, validatePath } = await import('@automaker/platform');
|
||||
initAllowedPaths();
|
||||
|
||||
// All paths are allowed when no restrictions configured
|
||||
expect(() => validatePath('/disallowed/file.txt')).not.toThrow();
|
||||
expect(validatePath('/disallowed/file.txt')).toBe(path.resolve('/disallowed/file.txt'));
|
||||
});
|
||||
|
||||
it('should resolve relative paths within allowed directory', async () => {
|
||||
const cwd = process.cwd();
|
||||
expect(allowed).toContain(path.resolve(cwd, "./relative/path"));
|
||||
process.env.ALLOWED_ROOT_DIRECTORY = cwd;
|
||||
process.env.DATA_DIR = '';
|
||||
|
||||
const { initAllowedPaths, validatePath } = await import('@automaker/platform');
|
||||
initAllowedPaths();
|
||||
|
||||
const result = validatePath('./file.txt');
|
||||
expect(result).toBe(path.resolve(cwd, './file.txt'));
|
||||
});
|
||||
});
|
||||
|
||||
describe("isPathAllowed", () => {
|
||||
it("should allow all paths (permissions disabled)", async () => {
|
||||
process.env.ALLOWED_PROJECT_DIRS = "/allowed/project";
|
||||
process.env.DATA_DIR = "";
|
||||
describe('getAllowedPaths', () => {
|
||||
it('should return array of allowed paths', async () => {
|
||||
process.env.ALLOWED_ROOT_DIRECTORY = '/projects';
|
||||
process.env.DATA_DIR = '/data';
|
||||
|
||||
const { initAllowedPaths, isPathAllowed } = await import(
|
||||
"@/lib/security.js"
|
||||
);
|
||||
initAllowedPaths();
|
||||
|
||||
// All paths are now allowed regardless of configuration
|
||||
expect(isPathAllowed("/allowed/project/file.txt")).toBe(true);
|
||||
expect(isPathAllowed("/not/allowed/file.txt")).toBe(true);
|
||||
expect(isPathAllowed("/tmp/file.txt")).toBe(true);
|
||||
expect(isPathAllowed("/etc/passwd")).toBe(true);
|
||||
expect(isPathAllowed("/any/path")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("validatePath", () => {
|
||||
it("should return resolved path for any path (permissions disabled)", async () => {
|
||||
process.env.ALLOWED_PROJECT_DIRS = "/allowed";
|
||||
process.env.DATA_DIR = "";
|
||||
|
||||
const { initAllowedPaths, validatePath } = await import(
|
||||
"@/lib/security.js"
|
||||
);
|
||||
initAllowedPaths();
|
||||
|
||||
const result = validatePath("/allowed/file.txt");
|
||||
expect(result).toBe(path.resolve("/allowed/file.txt"));
|
||||
});
|
||||
|
||||
it("should not throw error for any path (permissions disabled)", async () => {
|
||||
process.env.ALLOWED_PROJECT_DIRS = "/allowed";
|
||||
process.env.DATA_DIR = "";
|
||||
|
||||
const { initAllowedPaths, validatePath } = await import(
|
||||
"@/lib/security.js"
|
||||
);
|
||||
initAllowedPaths();
|
||||
|
||||
// All paths are now allowed, no errors thrown
|
||||
expect(() => validatePath("/disallowed/file.txt")).not.toThrow();
|
||||
expect(validatePath("/disallowed/file.txt")).toBe(
|
||||
path.resolve("/disallowed/file.txt")
|
||||
);
|
||||
});
|
||||
|
||||
it("should resolve relative paths", async () => {
|
||||
const cwd = process.cwd();
|
||||
process.env.ALLOWED_PROJECT_DIRS = cwd;
|
||||
process.env.DATA_DIR = "";
|
||||
|
||||
const { initAllowedPaths, validatePath } = await import(
|
||||
"@/lib/security.js"
|
||||
);
|
||||
initAllowedPaths();
|
||||
|
||||
const result = validatePath("./file.txt");
|
||||
expect(result).toBe(path.resolve(cwd, "./file.txt"));
|
||||
});
|
||||
});
|
||||
|
||||
describe("getAllowedPaths", () => {
|
||||
it("should return array of allowed paths", async () => {
|
||||
process.env.ALLOWED_PROJECT_DIRS = "/path1,/path2";
|
||||
process.env.DATA_DIR = "/data";
|
||||
|
||||
const { initAllowedPaths, getAllowedPaths } = await import(
|
||||
"@/lib/security.js"
|
||||
);
|
||||
const { initAllowedPaths, getAllowedPaths } = await import('@automaker/platform');
|
||||
initAllowedPaths();
|
||||
|
||||
const result = getAllowedPaths();
|
||||
expect(Array.isArray(result)).toBe(true);
|
||||
expect(result.length).toBeGreaterThan(0);
|
||||
expect(result.length).toBe(2);
|
||||
expect(result).toContain(path.resolve('/projects'));
|
||||
expect(result).toContain(path.resolve('/data'));
|
||||
});
|
||||
|
||||
it("should return resolved paths", async () => {
|
||||
process.env.ALLOWED_PROJECT_DIRS = "/test";
|
||||
process.env.DATA_DIR = "";
|
||||
it('should return resolved paths', async () => {
|
||||
process.env.ALLOWED_ROOT_DIRECTORY = '/test';
|
||||
process.env.DATA_DIR = '';
|
||||
|
||||
const { initAllowedPaths, getAllowedPaths } = await import(
|
||||
"@/lib/security.js"
|
||||
);
|
||||
const { initAllowedPaths, getAllowedPaths } = await import('@automaker/platform');
|
||||
initAllowedPaths();
|
||||
|
||||
const result = getAllowedPaths();
|
||||
expect(result[0]).toBe(path.resolve("/test"));
|
||||
expect(result[0]).toBe(path.resolve('/test'));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,482 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import {
|
||||
spawnJSONLProcess,
|
||||
spawnProcess,
|
||||
type SubprocessOptions,
|
||||
} from "@/lib/subprocess-manager.js";
|
||||
import * as cp from "child_process";
|
||||
import { EventEmitter } from "events";
|
||||
import { Readable } from "stream";
|
||||
import { collectAsyncGenerator } from "../../utils/helpers.js";
|
||||
|
||||
vi.mock("child_process");
|
||||
|
||||
describe("subprocess-manager.ts", () => {
|
||||
let consoleSpy: any;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
consoleSpy = {
|
||||
log: vi.spyOn(console, "log").mockImplementation(() => {}),
|
||||
error: vi.spyOn(console, "error").mockImplementation(() => {}),
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
consoleSpy.log.mockRestore();
|
||||
consoleSpy.error.mockRestore();
|
||||
});
|
||||
|
||||
/**
|
||||
* Helper to create a mock ChildProcess with stdout/stderr streams
|
||||
*/
|
||||
function createMockProcess(config: {
|
||||
stdoutLines?: string[];
|
||||
stderrLines?: string[];
|
||||
exitCode?: number;
|
||||
error?: Error;
|
||||
delayMs?: number;
|
||||
}) {
|
||||
const mockProcess = new EventEmitter() as any;
|
||||
|
||||
// Create readable streams for stdout and stderr
|
||||
const stdout = new Readable({ read() {} });
|
||||
const stderr = new Readable({ read() {} });
|
||||
|
||||
mockProcess.stdout = stdout;
|
||||
mockProcess.stderr = stderr;
|
||||
mockProcess.kill = vi.fn();
|
||||
|
||||
// Use process.nextTick to ensure readline interface is set up first
|
||||
process.nextTick(() => {
|
||||
// Emit stderr lines immediately
|
||||
if (config.stderrLines) {
|
||||
for (const line of config.stderrLines) {
|
||||
stderr.emit("data", Buffer.from(line));
|
||||
}
|
||||
}
|
||||
|
||||
// Emit stdout lines with small delays to ensure readline processes them
|
||||
const emitLines = async () => {
|
||||
if (config.stdoutLines) {
|
||||
for (const line of config.stdoutLines) {
|
||||
stdout.push(line + "\n");
|
||||
// Small delay to allow readline to process
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
}
|
||||
}
|
||||
|
||||
// Small delay before ending stream
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
stdout.push(null); // End stdout
|
||||
|
||||
// Small delay before exit
|
||||
await new Promise((resolve) =>
|
||||
setTimeout(resolve, config.delayMs ?? 10)
|
||||
);
|
||||
|
||||
// Emit exit or error
|
||||
if (config.error) {
|
||||
mockProcess.emit("error", config.error);
|
||||
} else {
|
||||
mockProcess.emit("exit", config.exitCode ?? 0);
|
||||
}
|
||||
};
|
||||
|
||||
emitLines();
|
||||
});
|
||||
|
||||
return mockProcess;
|
||||
}
|
||||
|
||||
describe("spawnJSONLProcess", () => {
|
||||
const baseOptions: SubprocessOptions = {
|
||||
command: "test-command",
|
||||
args: ["arg1", "arg2"],
|
||||
cwd: "/test/dir",
|
||||
};
|
||||
|
||||
it("should yield parsed JSONL objects line by line", async () => {
|
||||
const mockProcess = createMockProcess({
|
||||
stdoutLines: [
|
||||
'{"type":"start","id":1}',
|
||||
'{"type":"progress","value":50}',
|
||||
'{"type":"complete","result":"success"}',
|
||||
],
|
||||
exitCode: 0,
|
||||
});
|
||||
|
||||
vi.mocked(cp.spawn).mockReturnValue(mockProcess);
|
||||
|
||||
const generator = spawnJSONLProcess(baseOptions);
|
||||
const results = await collectAsyncGenerator(generator);
|
||||
|
||||
expect(results).toHaveLength(3);
|
||||
expect(results[0]).toEqual({ type: "start", id: 1 });
|
||||
expect(results[1]).toEqual({ type: "progress", value: 50 });
|
||||
expect(results[2]).toEqual({ type: "complete", result: "success" });
|
||||
});
|
||||
|
||||
it("should skip empty lines", async () => {
|
||||
const mockProcess = createMockProcess({
|
||||
stdoutLines: [
|
||||
'{"type":"first"}',
|
||||
"",
|
||||
" ",
|
||||
'{"type":"second"}',
|
||||
],
|
||||
exitCode: 0,
|
||||
});
|
||||
|
||||
vi.mocked(cp.spawn).mockReturnValue(mockProcess);
|
||||
|
||||
const generator = spawnJSONLProcess(baseOptions);
|
||||
const results = await collectAsyncGenerator(generator);
|
||||
|
||||
expect(results).toHaveLength(2);
|
||||
expect(results[0]).toEqual({ type: "first" });
|
||||
expect(results[1]).toEqual({ type: "second" });
|
||||
});
|
||||
|
||||
it("should yield error for malformed JSON and continue processing", async () => {
|
||||
const mockProcess = createMockProcess({
|
||||
stdoutLines: [
|
||||
'{"type":"valid"}',
|
||||
'{invalid json}',
|
||||
'{"type":"also_valid"}',
|
||||
],
|
||||
exitCode: 0,
|
||||
});
|
||||
|
||||
vi.mocked(cp.spawn).mockReturnValue(mockProcess);
|
||||
|
||||
const generator = spawnJSONLProcess(baseOptions);
|
||||
const results = await collectAsyncGenerator(generator);
|
||||
|
||||
expect(results).toHaveLength(3);
|
||||
expect(results[0]).toEqual({ type: "valid" });
|
||||
expect(results[1]).toMatchObject({
|
||||
type: "error",
|
||||
error: expect.stringContaining("Failed to parse output"),
|
||||
});
|
||||
expect(results[2]).toEqual({ type: "also_valid" });
|
||||
});
|
||||
|
||||
it("should collect stderr output", async () => {
|
||||
const mockProcess = createMockProcess({
|
||||
stdoutLines: ['{"type":"test"}'],
|
||||
stderrLines: ["Warning: something happened", "Error: critical issue"],
|
||||
exitCode: 0,
|
||||
});
|
||||
|
||||
vi.mocked(cp.spawn).mockReturnValue(mockProcess);
|
||||
|
||||
const generator = spawnJSONLProcess(baseOptions);
|
||||
await collectAsyncGenerator(generator);
|
||||
|
||||
expect(consoleSpy.error).toHaveBeenCalledWith(
|
||||
expect.stringContaining("Warning: something happened")
|
||||
);
|
||||
expect(consoleSpy.error).toHaveBeenCalledWith(
|
||||
expect.stringContaining("Error: critical issue")
|
||||
);
|
||||
});
|
||||
|
||||
it("should yield error on non-zero exit code", async () => {
|
||||
const mockProcess = createMockProcess({
|
||||
stdoutLines: ['{"type":"started"}'],
|
||||
stderrLines: ["Process failed with error"],
|
||||
exitCode: 1,
|
||||
});
|
||||
|
||||
vi.mocked(cp.spawn).mockReturnValue(mockProcess);
|
||||
|
||||
const generator = spawnJSONLProcess(baseOptions);
|
||||
const results = await collectAsyncGenerator(generator);
|
||||
|
||||
expect(results).toHaveLength(2);
|
||||
expect(results[0]).toEqual({ type: "started" });
|
||||
expect(results[1]).toMatchObject({
|
||||
type: "error",
|
||||
error: expect.stringContaining("Process failed with error"),
|
||||
});
|
||||
});
|
||||
|
||||
it("should yield error with exit code when stderr is empty", async () => {
|
||||
const mockProcess = createMockProcess({
|
||||
stdoutLines: ['{"type":"test"}'],
|
||||
exitCode: 127,
|
||||
});
|
||||
|
||||
vi.mocked(cp.spawn).mockReturnValue(mockProcess);
|
||||
|
||||
const generator = spawnJSONLProcess(baseOptions);
|
||||
const results = await collectAsyncGenerator(generator);
|
||||
|
||||
expect(results).toHaveLength(2);
|
||||
expect(results[1]).toMatchObject({
|
||||
type: "error",
|
||||
error: "Process exited with code 127",
|
||||
});
|
||||
});
|
||||
|
||||
it("should handle process spawn errors", async () => {
|
||||
const mockProcess = createMockProcess({
|
||||
error: new Error("Command not found"),
|
||||
});
|
||||
|
||||
vi.mocked(cp.spawn).mockReturnValue(mockProcess);
|
||||
|
||||
const generator = spawnJSONLProcess(baseOptions);
|
||||
const results = await collectAsyncGenerator(generator);
|
||||
|
||||
// When process.on('error') fires, exitCode is null
|
||||
// The generator should handle this gracefully
|
||||
expect(results).toEqual([]);
|
||||
});
|
||||
|
||||
it("should kill process on AbortController signal", async () => {
|
||||
const abortController = new AbortController();
|
||||
const mockProcess = createMockProcess({
|
||||
stdoutLines: ['{"type":"start"}'],
|
||||
exitCode: 0,
|
||||
delayMs: 100, // Delay to allow abort
|
||||
});
|
||||
|
||||
vi.mocked(cp.spawn).mockReturnValue(mockProcess);
|
||||
|
||||
const generator = spawnJSONLProcess({
|
||||
...baseOptions,
|
||||
abortController,
|
||||
});
|
||||
|
||||
// Start consuming the generator
|
||||
const promise = collectAsyncGenerator(generator);
|
||||
|
||||
// Abort after a short delay
|
||||
setTimeout(() => abortController.abort(), 20);
|
||||
|
||||
await promise;
|
||||
|
||||
expect(mockProcess.kill).toHaveBeenCalledWith("SIGTERM");
|
||||
expect(consoleSpy.log).toHaveBeenCalledWith(
|
||||
expect.stringContaining("Abort signal received")
|
||||
);
|
||||
});
|
||||
|
||||
// Note: Timeout behavior tests are omitted from unit tests as they involve
|
||||
// complex timing interactions that are difficult to mock reliably.
|
||||
// These scenarios are better covered by integration tests with real subprocesses.
|
||||
|
||||
it("should spawn process with correct arguments", async () => {
|
||||
const mockProcess = createMockProcess({ exitCode: 0 });
|
||||
vi.mocked(cp.spawn).mockReturnValue(mockProcess);
|
||||
|
||||
const options: SubprocessOptions = {
|
||||
command: "my-command",
|
||||
args: ["--flag", "value"],
|
||||
cwd: "/work/dir",
|
||||
env: { CUSTOM_VAR: "test" },
|
||||
};
|
||||
|
||||
const generator = spawnJSONLProcess(options);
|
||||
await collectAsyncGenerator(generator);
|
||||
|
||||
expect(cp.spawn).toHaveBeenCalledWith("my-command", ["--flag", "value"], {
|
||||
cwd: "/work/dir",
|
||||
env: expect.objectContaining({ CUSTOM_VAR: "test" }),
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
});
|
||||
|
||||
it("should merge env with process.env", async () => {
|
||||
const mockProcess = createMockProcess({ exitCode: 0 });
|
||||
vi.mocked(cp.spawn).mockReturnValue(mockProcess);
|
||||
|
||||
const options: SubprocessOptions = {
|
||||
command: "test",
|
||||
args: [],
|
||||
cwd: "/test",
|
||||
env: { CUSTOM: "value" },
|
||||
};
|
||||
|
||||
const generator = spawnJSONLProcess(options);
|
||||
await collectAsyncGenerator(generator);
|
||||
|
||||
expect(cp.spawn).toHaveBeenCalledWith(
|
||||
"test",
|
||||
[],
|
||||
expect.objectContaining({
|
||||
env: expect.objectContaining({
|
||||
CUSTOM: "value",
|
||||
// Should also include existing process.env
|
||||
NODE_ENV: process.env.NODE_ENV,
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("should handle complex JSON objects", async () => {
|
||||
const complexObject = {
|
||||
type: "complex",
|
||||
nested: { deep: { value: [1, 2, 3] } },
|
||||
array: [{ id: 1 }, { id: 2 }],
|
||||
string: "with \"quotes\" and \\backslashes",
|
||||
};
|
||||
|
||||
const mockProcess = createMockProcess({
|
||||
stdoutLines: [JSON.stringify(complexObject)],
|
||||
exitCode: 0,
|
||||
});
|
||||
|
||||
vi.mocked(cp.spawn).mockReturnValue(mockProcess);
|
||||
|
||||
const generator = spawnJSONLProcess(baseOptions);
|
||||
const results = await collectAsyncGenerator(generator);
|
||||
|
||||
expect(results).toHaveLength(1);
|
||||
expect(results[0]).toEqual(complexObject);
|
||||
});
|
||||
});
|
||||
|
||||
describe("spawnProcess", () => {
|
||||
const baseOptions: SubprocessOptions = {
|
||||
command: "test-command",
|
||||
args: ["arg1"],
|
||||
cwd: "/test",
|
||||
};
|
||||
|
||||
it("should collect stdout and stderr", async () => {
|
||||
const mockProcess = new EventEmitter() as any;
|
||||
const stdout = new Readable({ read() {} });
|
||||
const stderr = new Readable({ read() {} });
|
||||
|
||||
mockProcess.stdout = stdout;
|
||||
mockProcess.stderr = stderr;
|
||||
mockProcess.kill = vi.fn();
|
||||
|
||||
vi.mocked(cp.spawn).mockReturnValue(mockProcess);
|
||||
|
||||
setTimeout(() => {
|
||||
stdout.push("line 1\n");
|
||||
stdout.push("line 2\n");
|
||||
stdout.push(null);
|
||||
|
||||
stderr.push("error 1\n");
|
||||
stderr.push("error 2\n");
|
||||
stderr.push(null);
|
||||
|
||||
mockProcess.emit("exit", 0);
|
||||
}, 10);
|
||||
|
||||
const result = await spawnProcess(baseOptions);
|
||||
|
||||
expect(result.stdout).toBe("line 1\nline 2\n");
|
||||
expect(result.stderr).toBe("error 1\nerror 2\n");
|
||||
expect(result.exitCode).toBe(0);
|
||||
});
|
||||
|
||||
it("should return correct exit code", async () => {
|
||||
const mockProcess = new EventEmitter() as any;
|
||||
mockProcess.stdout = new Readable({ read() {} });
|
||||
mockProcess.stderr = new Readable({ read() {} });
|
||||
mockProcess.kill = vi.fn();
|
||||
|
||||
vi.mocked(cp.spawn).mockReturnValue(mockProcess);
|
||||
|
||||
setTimeout(() => {
|
||||
mockProcess.stdout.push(null);
|
||||
mockProcess.stderr.push(null);
|
||||
mockProcess.emit("exit", 42);
|
||||
}, 10);
|
||||
|
||||
const result = await spawnProcess(baseOptions);
|
||||
|
||||
expect(result.exitCode).toBe(42);
|
||||
});
|
||||
|
||||
it("should handle process errors", async () => {
|
||||
const mockProcess = new EventEmitter() as any;
|
||||
mockProcess.stdout = new Readable({ read() {} });
|
||||
mockProcess.stderr = new Readable({ read() {} });
|
||||
mockProcess.kill = vi.fn();
|
||||
|
||||
vi.mocked(cp.spawn).mockReturnValue(mockProcess);
|
||||
|
||||
setTimeout(() => {
|
||||
mockProcess.emit("error", new Error("Spawn failed"));
|
||||
}, 10);
|
||||
|
||||
await expect(spawnProcess(baseOptions)).rejects.toThrow("Spawn failed");
|
||||
});
|
||||
|
||||
it("should handle AbortController signal", async () => {
|
||||
const abortController = new AbortController();
|
||||
const mockProcess = new EventEmitter() as any;
|
||||
mockProcess.stdout = new Readable({ read() {} });
|
||||
mockProcess.stderr = new Readable({ read() {} });
|
||||
mockProcess.kill = vi.fn();
|
||||
|
||||
vi.mocked(cp.spawn).mockReturnValue(mockProcess);
|
||||
|
||||
setTimeout(() => abortController.abort(), 20);
|
||||
|
||||
await expect(
|
||||
spawnProcess({ ...baseOptions, abortController })
|
||||
).rejects.toThrow("Process aborted");
|
||||
|
||||
expect(mockProcess.kill).toHaveBeenCalledWith("SIGTERM");
|
||||
});
|
||||
|
||||
it("should spawn with correct options", async () => {
|
||||
const mockProcess = new EventEmitter() as any;
|
||||
mockProcess.stdout = new Readable({ read() {} });
|
||||
mockProcess.stderr = new Readable({ read() {} });
|
||||
mockProcess.kill = vi.fn();
|
||||
|
||||
vi.mocked(cp.spawn).mockReturnValue(mockProcess);
|
||||
|
||||
setTimeout(() => {
|
||||
mockProcess.stdout.push(null);
|
||||
mockProcess.stderr.push(null);
|
||||
mockProcess.emit("exit", 0);
|
||||
}, 10);
|
||||
|
||||
const options: SubprocessOptions = {
|
||||
command: "my-cmd",
|
||||
args: ["--verbose"],
|
||||
cwd: "/my/dir",
|
||||
env: { MY_VAR: "value" },
|
||||
};
|
||||
|
||||
await spawnProcess(options);
|
||||
|
||||
expect(cp.spawn).toHaveBeenCalledWith("my-cmd", ["--verbose"], {
|
||||
cwd: "/my/dir",
|
||||
env: expect.objectContaining({ MY_VAR: "value" }),
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
});
|
||||
|
||||
it("should handle empty stdout and stderr", async () => {
|
||||
const mockProcess = new EventEmitter() as any;
|
||||
mockProcess.stdout = new Readable({ read() {} });
|
||||
mockProcess.stderr = new Readable({ read() {} });
|
||||
mockProcess.kill = vi.fn();
|
||||
|
||||
vi.mocked(cp.spawn).mockReturnValue(mockProcess);
|
||||
|
||||
setTimeout(() => {
|
||||
mockProcess.stdout.push(null);
|
||||
mockProcess.stderr.push(null);
|
||||
mockProcess.emit("exit", 0);
|
||||
}, 10);
|
||||
|
||||
const result = await spawnProcess(baseOptions);
|
||||
|
||||
expect(result.stdout).toBe("");
|
||||
expect(result.stderr).toBe("");
|
||||
expect(result.exitCode).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import {
|
||||
readWorktreeMetadata,
|
||||
writeWorktreeMetadata,
|
||||
@@ -8,12 +8,12 @@ import {
|
||||
deleteWorktreeMetadata,
|
||||
type WorktreeMetadata,
|
||||
type WorktreePRInfo,
|
||||
} from "@/lib/worktree-metadata.js";
|
||||
import fs from "fs/promises";
|
||||
import path from "path";
|
||||
import os from "os";
|
||||
} from '@/lib/worktree-metadata.js';
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
import os from 'os';
|
||||
|
||||
describe("worktree-metadata.ts", () => {
|
||||
describe('worktree-metadata.ts', () => {
|
||||
let testProjectPath: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
@@ -29,10 +29,10 @@ describe("worktree-metadata.ts", () => {
|
||||
}
|
||||
});
|
||||
|
||||
describe("sanitizeBranchName", () => {
|
||||
describe('sanitizeBranchName', () => {
|
||||
// Test through readWorktreeMetadata and writeWorktreeMetadata
|
||||
it("should sanitize branch names with invalid characters", async () => {
|
||||
const branch = "feature/test-branch";
|
||||
it('should sanitize branch names with invalid characters', async () => {
|
||||
const branch = 'feature/test-branch';
|
||||
const metadata: WorktreeMetadata = {
|
||||
branch,
|
||||
createdAt: new Date().toISOString(),
|
||||
@@ -43,8 +43,8 @@ describe("worktree-metadata.ts", () => {
|
||||
expect(result).toEqual(metadata);
|
||||
});
|
||||
|
||||
it("should sanitize branch names with Windows invalid characters", async () => {
|
||||
const branch = "feature:test*branch?";
|
||||
it('should sanitize branch names with Windows invalid characters', async () => {
|
||||
const branch = 'feature:test*branch?';
|
||||
const metadata: WorktreeMetadata = {
|
||||
branch,
|
||||
createdAt: new Date().toISOString(),
|
||||
@@ -55,8 +55,8 @@ describe("worktree-metadata.ts", () => {
|
||||
expect(result).toEqual(metadata);
|
||||
});
|
||||
|
||||
it("should sanitize Windows reserved names", async () => {
|
||||
const branch = "CON";
|
||||
it('should sanitize Windows reserved names', async () => {
|
||||
const branch = 'CON';
|
||||
const metadata: WorktreeMetadata = {
|
||||
branch,
|
||||
createdAt: new Date().toISOString(),
|
||||
@@ -66,16 +66,42 @@ describe("worktree-metadata.ts", () => {
|
||||
const result = await readWorktreeMetadata(testProjectPath, branch);
|
||||
expect(result).toEqual(metadata);
|
||||
});
|
||||
|
||||
it('should handle empty branch name', async () => {
|
||||
const branch = '';
|
||||
const metadata: WorktreeMetadata = {
|
||||
branch: 'branch',
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
// Empty branch name should be sanitized to "_branch"
|
||||
await writeWorktreeMetadata(testProjectPath, branch, metadata);
|
||||
const result = await readWorktreeMetadata(testProjectPath, branch);
|
||||
expect(result).toEqual(metadata);
|
||||
});
|
||||
|
||||
it('should handle branch name that becomes empty after sanitization', async () => {
|
||||
// Test branch that would become empty after removing invalid chars
|
||||
const branch = '///';
|
||||
const metadata: WorktreeMetadata = {
|
||||
branch: 'branch',
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
await writeWorktreeMetadata(testProjectPath, branch, metadata);
|
||||
const result = await readWorktreeMetadata(testProjectPath, branch);
|
||||
expect(result).toEqual(metadata);
|
||||
});
|
||||
});
|
||||
|
||||
describe("readWorktreeMetadata", () => {
|
||||
describe('readWorktreeMetadata', () => {
|
||||
it("should return null when metadata file doesn't exist", async () => {
|
||||
const result = await readWorktreeMetadata(testProjectPath, "nonexistent-branch");
|
||||
const result = await readWorktreeMetadata(testProjectPath, 'nonexistent-branch');
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("should read existing metadata", async () => {
|
||||
const branch = "test-branch";
|
||||
it('should read existing metadata', async () => {
|
||||
const branch = 'test-branch';
|
||||
const metadata: WorktreeMetadata = {
|
||||
branch,
|
||||
createdAt: new Date().toISOString(),
|
||||
@@ -86,16 +112,16 @@ describe("worktree-metadata.ts", () => {
|
||||
expect(result).toEqual(metadata);
|
||||
});
|
||||
|
||||
it("should read metadata with PR info", async () => {
|
||||
const branch = "pr-branch";
|
||||
it('should read metadata with PR info', async () => {
|
||||
const branch = 'pr-branch';
|
||||
const metadata: WorktreeMetadata = {
|
||||
branch,
|
||||
createdAt: new Date().toISOString(),
|
||||
pr: {
|
||||
number: 123,
|
||||
url: "https://github.com/owner/repo/pull/123",
|
||||
title: "Test PR",
|
||||
state: "open",
|
||||
url: 'https://github.com/owner/repo/pull/123',
|
||||
title: 'Test PR',
|
||||
state: 'open',
|
||||
createdAt: new Date().toISOString(),
|
||||
},
|
||||
};
|
||||
@@ -106,9 +132,9 @@ describe("worktree-metadata.ts", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("writeWorktreeMetadata", () => {
|
||||
describe('writeWorktreeMetadata', () => {
|
||||
it("should create metadata directory if it doesn't exist", async () => {
|
||||
const branch = "new-branch";
|
||||
const branch = 'new-branch';
|
||||
const metadata: WorktreeMetadata = {
|
||||
branch,
|
||||
createdAt: new Date().toISOString(),
|
||||
@@ -119,8 +145,8 @@ describe("worktree-metadata.ts", () => {
|
||||
expect(result).toEqual(metadata);
|
||||
});
|
||||
|
||||
it("should overwrite existing metadata", async () => {
|
||||
const branch = "existing-branch";
|
||||
it('should overwrite existing metadata', async () => {
|
||||
const branch = 'existing-branch';
|
||||
const metadata1: WorktreeMetadata = {
|
||||
branch,
|
||||
createdAt: new Date().toISOString(),
|
||||
@@ -130,9 +156,9 @@ describe("worktree-metadata.ts", () => {
|
||||
createdAt: new Date().toISOString(),
|
||||
pr: {
|
||||
number: 456,
|
||||
url: "https://github.com/owner/repo/pull/456",
|
||||
title: "Updated PR",
|
||||
state: "closed",
|
||||
url: 'https://github.com/owner/repo/pull/456',
|
||||
title: 'Updated PR',
|
||||
state: 'closed',
|
||||
createdAt: new Date().toISOString(),
|
||||
},
|
||||
};
|
||||
@@ -144,14 +170,14 @@ describe("worktree-metadata.ts", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("updateWorktreePRInfo", () => {
|
||||
describe('updateWorktreePRInfo', () => {
|
||||
it("should create new metadata if it doesn't exist", async () => {
|
||||
const branch = "new-pr-branch";
|
||||
const branch = 'new-pr-branch';
|
||||
const prInfo: WorktreePRInfo = {
|
||||
number: 789,
|
||||
url: "https://github.com/owner/repo/pull/789",
|
||||
title: "New PR",
|
||||
state: "open",
|
||||
url: 'https://github.com/owner/repo/pull/789',
|
||||
title: 'New PR',
|
||||
state: 'open',
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
@@ -162,8 +188,8 @@ describe("worktree-metadata.ts", () => {
|
||||
expect(result?.pr).toEqual(prInfo);
|
||||
});
|
||||
|
||||
it("should update existing metadata with PR info", async () => {
|
||||
const branch = "existing-pr-branch";
|
||||
it('should update existing metadata with PR info', async () => {
|
||||
const branch = 'existing-pr-branch';
|
||||
const metadata: WorktreeMetadata = {
|
||||
branch,
|
||||
createdAt: new Date().toISOString(),
|
||||
@@ -173,9 +199,9 @@ describe("worktree-metadata.ts", () => {
|
||||
|
||||
const prInfo: WorktreePRInfo = {
|
||||
number: 999,
|
||||
url: "https://github.com/owner/repo/pull/999",
|
||||
title: "Updated PR",
|
||||
state: "merged",
|
||||
url: 'https://github.com/owner/repo/pull/999',
|
||||
title: 'Updated PR',
|
||||
state: 'merged',
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
@@ -184,8 +210,8 @@ describe("worktree-metadata.ts", () => {
|
||||
expect(result?.pr).toEqual(prInfo);
|
||||
});
|
||||
|
||||
it("should preserve existing metadata when updating PR info", async () => {
|
||||
const branch = "preserve-branch";
|
||||
it('should preserve existing metadata when updating PR info', async () => {
|
||||
const branch = 'preserve-branch';
|
||||
const originalCreatedAt = new Date().toISOString();
|
||||
const metadata: WorktreeMetadata = {
|
||||
branch,
|
||||
@@ -196,9 +222,9 @@ describe("worktree-metadata.ts", () => {
|
||||
|
||||
const prInfo: WorktreePRInfo = {
|
||||
number: 111,
|
||||
url: "https://github.com/owner/repo/pull/111",
|
||||
title: "PR",
|
||||
state: "open",
|
||||
url: 'https://github.com/owner/repo/pull/111',
|
||||
title: 'PR',
|
||||
state: 'open',
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
@@ -209,14 +235,14 @@ describe("worktree-metadata.ts", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("getWorktreePRInfo", () => {
|
||||
describe('getWorktreePRInfo', () => {
|
||||
it("should return null when metadata doesn't exist", async () => {
|
||||
const result = await getWorktreePRInfo(testProjectPath, "nonexistent");
|
||||
const result = await getWorktreePRInfo(testProjectPath, 'nonexistent');
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("should return null when metadata exists but has no PR info", async () => {
|
||||
const branch = "no-pr-branch";
|
||||
it('should return null when metadata exists but has no PR info', async () => {
|
||||
const branch = 'no-pr-branch';
|
||||
const metadata: WorktreeMetadata = {
|
||||
branch,
|
||||
createdAt: new Date().toISOString(),
|
||||
@@ -227,13 +253,13 @@ describe("worktree-metadata.ts", () => {
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("should return PR info when it exists", async () => {
|
||||
const branch = "has-pr-branch";
|
||||
it('should return PR info when it exists', async () => {
|
||||
const branch = 'has-pr-branch';
|
||||
const prInfo: WorktreePRInfo = {
|
||||
number: 222,
|
||||
url: "https://github.com/owner/repo/pull/222",
|
||||
title: "Has PR",
|
||||
state: "open",
|
||||
url: 'https://github.com/owner/repo/pull/222',
|
||||
title: 'Has PR',
|
||||
state: 'open',
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
@@ -243,23 +269,23 @@ describe("worktree-metadata.ts", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("readAllWorktreeMetadata", () => {
|
||||
describe('readAllWorktreeMetadata', () => {
|
||||
it("should return empty map when worktrees directory doesn't exist", async () => {
|
||||
const result = await readAllWorktreeMetadata(testProjectPath);
|
||||
expect(result.size).toBe(0);
|
||||
});
|
||||
|
||||
it("should return empty map when worktrees directory is empty", async () => {
|
||||
const worktreesDir = path.join(testProjectPath, ".automaker", "worktrees");
|
||||
it('should return empty map when worktrees directory is empty', async () => {
|
||||
const worktreesDir = path.join(testProjectPath, '.automaker', 'worktrees');
|
||||
await fs.mkdir(worktreesDir, { recursive: true });
|
||||
|
||||
const result = await readAllWorktreeMetadata(testProjectPath);
|
||||
expect(result.size).toBe(0);
|
||||
});
|
||||
|
||||
it("should read all worktree metadata", async () => {
|
||||
const branch1 = "branch-1";
|
||||
const branch2 = "branch-2";
|
||||
it('should read all worktree metadata', async () => {
|
||||
const branch1 = 'branch-1';
|
||||
const branch2 = 'branch-2';
|
||||
const metadata1: WorktreeMetadata = {
|
||||
branch: branch1,
|
||||
createdAt: new Date().toISOString(),
|
||||
@@ -269,9 +295,9 @@ describe("worktree-metadata.ts", () => {
|
||||
createdAt: new Date().toISOString(),
|
||||
pr: {
|
||||
number: 333,
|
||||
url: "https://github.com/owner/repo/pull/333",
|
||||
title: "PR 3",
|
||||
state: "open",
|
||||
url: 'https://github.com/owner/repo/pull/333',
|
||||
title: 'PR 3',
|
||||
state: 'open',
|
||||
createdAt: new Date().toISOString(),
|
||||
},
|
||||
};
|
||||
@@ -285,12 +311,12 @@ describe("worktree-metadata.ts", () => {
|
||||
expect(result.get(branch2)).toEqual(metadata2);
|
||||
});
|
||||
|
||||
it("should skip directories without worktree.json", async () => {
|
||||
const worktreesDir = path.join(testProjectPath, ".automaker", "worktrees");
|
||||
const emptyDir = path.join(worktreesDir, "empty-dir");
|
||||
it('should skip directories without worktree.json', async () => {
|
||||
const worktreesDir = path.join(testProjectPath, '.automaker', 'worktrees');
|
||||
const emptyDir = path.join(worktreesDir, 'empty-dir');
|
||||
await fs.mkdir(emptyDir, { recursive: true });
|
||||
|
||||
const branch = "valid-branch";
|
||||
const branch = 'valid-branch';
|
||||
const metadata: WorktreeMetadata = {
|
||||
branch,
|
||||
createdAt: new Date().toISOString(),
|
||||
@@ -302,13 +328,13 @@ describe("worktree-metadata.ts", () => {
|
||||
expect(result.get(branch)).toEqual(metadata);
|
||||
});
|
||||
|
||||
it("should skip files in worktrees directory", async () => {
|
||||
const worktreesDir = path.join(testProjectPath, ".automaker", "worktrees");
|
||||
it('should skip files in worktrees directory', async () => {
|
||||
const worktreesDir = path.join(testProjectPath, '.automaker', 'worktrees');
|
||||
await fs.mkdir(worktreesDir, { recursive: true });
|
||||
const filePath = path.join(worktreesDir, "not-a-dir.txt");
|
||||
await fs.writeFile(filePath, "content");
|
||||
const filePath = path.join(worktreesDir, 'not-a-dir.txt');
|
||||
await fs.writeFile(filePath, 'content');
|
||||
|
||||
const branch = "valid-branch";
|
||||
const branch = 'valid-branch';
|
||||
const metadata: WorktreeMetadata = {
|
||||
branch,
|
||||
createdAt: new Date().toISOString(),
|
||||
@@ -320,14 +346,14 @@ describe("worktree-metadata.ts", () => {
|
||||
expect(result.get(branch)).toEqual(metadata);
|
||||
});
|
||||
|
||||
it("should skip directories with malformed JSON", async () => {
|
||||
const worktreesDir = path.join(testProjectPath, ".automaker", "worktrees");
|
||||
const badDir = path.join(worktreesDir, "bad-dir");
|
||||
it('should skip directories with malformed JSON', async () => {
|
||||
const worktreesDir = path.join(testProjectPath, '.automaker', 'worktrees');
|
||||
const badDir = path.join(worktreesDir, 'bad-dir');
|
||||
await fs.mkdir(badDir, { recursive: true });
|
||||
const badJsonPath = path.join(badDir, "worktree.json");
|
||||
await fs.writeFile(badJsonPath, "not valid json");
|
||||
const badJsonPath = path.join(badDir, 'worktree.json');
|
||||
await fs.writeFile(badJsonPath, 'not valid json');
|
||||
|
||||
const branch = "valid-branch";
|
||||
const branch = 'valid-branch';
|
||||
const metadata: WorktreeMetadata = {
|
||||
branch,
|
||||
createdAt: new Date().toISOString(),
|
||||
@@ -340,9 +366,9 @@ describe("worktree-metadata.ts", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("deleteWorktreeMetadata", () => {
|
||||
it("should delete worktree metadata directory", async () => {
|
||||
const branch = "to-delete";
|
||||
describe('deleteWorktreeMetadata', () => {
|
||||
it('should delete worktree metadata directory', async () => {
|
||||
const branch = 'to-delete';
|
||||
const metadata: WorktreeMetadata = {
|
||||
branch,
|
||||
createdAt: new Date().toISOString(),
|
||||
@@ -359,10 +385,7 @@ describe("worktree-metadata.ts", () => {
|
||||
|
||||
it("should handle deletion when metadata doesn't exist", async () => {
|
||||
// Should not throw
|
||||
await expect(
|
||||
deleteWorktreeMetadata(testProjectPath, "nonexistent")
|
||||
).resolves.toBeUndefined();
|
||||
await expect(deleteWorktreeMetadata(testProjectPath, 'nonexistent')).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,23 +1,21 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { BaseProvider } from "@/providers/base-provider.js";
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { BaseProvider } from '@/providers/base-provider.js';
|
||||
import type {
|
||||
ProviderConfig,
|
||||
ExecuteOptions,
|
||||
ProviderMessage,
|
||||
InstallationStatus,
|
||||
ModelDefinition,
|
||||
} from "@/providers/types.js";
|
||||
} from '@automaker/types';
|
||||
|
||||
// Concrete implementation for testing the abstract class
|
||||
class TestProvider extends BaseProvider {
|
||||
getName(): string {
|
||||
return "test-provider";
|
||||
return 'test-provider';
|
||||
}
|
||||
|
||||
async *executeQuery(
|
||||
_options: ExecuteOptions
|
||||
): AsyncGenerator<ProviderMessage> {
|
||||
yield { type: "text", text: "test response" };
|
||||
async *executeQuery(_options: ExecuteOptions): AsyncGenerator<ProviderMessage> {
|
||||
yield { type: 'text', text: 'test response' };
|
||||
}
|
||||
|
||||
async detectInstallation(): Promise<InstallationStatus> {
|
||||
@@ -25,37 +23,35 @@ class TestProvider extends BaseProvider {
|
||||
}
|
||||
|
||||
getAvailableModels(): ModelDefinition[] {
|
||||
return [
|
||||
{ id: "test-model-1", name: "Test Model 1", description: "A test model" },
|
||||
];
|
||||
return [{ id: 'test-model-1', name: 'Test Model 1', description: 'A test model' }];
|
||||
}
|
||||
}
|
||||
|
||||
describe("base-provider.ts", () => {
|
||||
describe("constructor", () => {
|
||||
it("should initialize with empty config when none provided", () => {
|
||||
describe('base-provider.ts', () => {
|
||||
describe('constructor', () => {
|
||||
it('should initialize with empty config when none provided', () => {
|
||||
const provider = new TestProvider();
|
||||
expect(provider.getConfig()).toEqual({});
|
||||
});
|
||||
|
||||
it("should initialize with provided config", () => {
|
||||
it('should initialize with provided config', () => {
|
||||
const config: ProviderConfig = {
|
||||
apiKey: "test-key",
|
||||
baseUrl: "https://test.com",
|
||||
apiKey: 'test-key',
|
||||
baseUrl: 'https://test.com',
|
||||
};
|
||||
const provider = new TestProvider(config);
|
||||
expect(provider.getConfig()).toEqual(config);
|
||||
});
|
||||
|
||||
it("should call getName() during initialization", () => {
|
||||
it('should call getName() during initialization', () => {
|
||||
const provider = new TestProvider();
|
||||
expect(provider.getName()).toBe("test-provider");
|
||||
expect(provider.getName()).toBe('test-provider');
|
||||
});
|
||||
});
|
||||
|
||||
describe("validateConfig", () => {
|
||||
it("should return valid when config exists", () => {
|
||||
const provider = new TestProvider({ apiKey: "test" });
|
||||
describe('validateConfig', () => {
|
||||
it('should return valid when config exists', () => {
|
||||
const provider = new TestProvider({ apiKey: 'test' });
|
||||
const result = provider.validateConfig();
|
||||
|
||||
expect(result.valid).toBe(true);
|
||||
@@ -63,7 +59,7 @@ describe("base-provider.ts", () => {
|
||||
expect(result.warnings).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("should return invalid when config is undefined", () => {
|
||||
it('should return invalid when config is undefined', () => {
|
||||
// Create provider without config
|
||||
const provider = new TestProvider();
|
||||
// Manually set config to undefined to test edge case
|
||||
@@ -72,10 +68,10 @@ describe("base-provider.ts", () => {
|
||||
const result = provider.validateConfig();
|
||||
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors).toContain("Provider config is missing");
|
||||
expect(result.errors).toContain('Provider config is missing');
|
||||
});
|
||||
|
||||
it("should return valid for empty config object", () => {
|
||||
it('should return valid for empty config object', () => {
|
||||
const provider = new TestProvider({});
|
||||
const result = provider.validateConfig();
|
||||
|
||||
@@ -83,53 +79,53 @@ describe("base-provider.ts", () => {
|
||||
expect(result.errors).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("should include warnings array in result", () => {
|
||||
it('should include warnings array in result', () => {
|
||||
const provider = new TestProvider();
|
||||
const result = provider.validateConfig();
|
||||
|
||||
expect(result).toHaveProperty("warnings");
|
||||
expect(result).toHaveProperty('warnings');
|
||||
expect(Array.isArray(result.warnings)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("supportsFeature", () => {
|
||||
describe('supportsFeature', () => {
|
||||
it("should support 'tools' feature", () => {
|
||||
const provider = new TestProvider();
|
||||
expect(provider.supportsFeature("tools")).toBe(true);
|
||||
expect(provider.supportsFeature('tools')).toBe(true);
|
||||
});
|
||||
|
||||
it("should support 'text' feature", () => {
|
||||
const provider = new TestProvider();
|
||||
expect(provider.supportsFeature("text")).toBe(true);
|
||||
expect(provider.supportsFeature('text')).toBe(true);
|
||||
});
|
||||
|
||||
it("should not support unknown features", () => {
|
||||
it('should not support unknown features', () => {
|
||||
const provider = new TestProvider();
|
||||
expect(provider.supportsFeature("vision")).toBe(false);
|
||||
expect(provider.supportsFeature("mcp")).toBe(false);
|
||||
expect(provider.supportsFeature("unknown")).toBe(false);
|
||||
expect(provider.supportsFeature('vision')).toBe(false);
|
||||
expect(provider.supportsFeature('mcp')).toBe(false);
|
||||
expect(provider.supportsFeature('unknown')).toBe(false);
|
||||
});
|
||||
|
||||
it("should be case-sensitive", () => {
|
||||
it('should be case-sensitive', () => {
|
||||
const provider = new TestProvider();
|
||||
expect(provider.supportsFeature("TOOLS")).toBe(false);
|
||||
expect(provider.supportsFeature("Text")).toBe(false);
|
||||
expect(provider.supportsFeature('TOOLS')).toBe(false);
|
||||
expect(provider.supportsFeature('Text')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getConfig", () => {
|
||||
it("should return current config", () => {
|
||||
describe('getConfig', () => {
|
||||
it('should return current config', () => {
|
||||
const config: ProviderConfig = {
|
||||
apiKey: "test-key",
|
||||
model: "test-model",
|
||||
apiKey: 'test-key',
|
||||
model: 'test-model',
|
||||
};
|
||||
const provider = new TestProvider(config);
|
||||
|
||||
expect(provider.getConfig()).toEqual(config);
|
||||
});
|
||||
|
||||
it("should return same reference", () => {
|
||||
const config: ProviderConfig = { apiKey: "test" };
|
||||
it('should return same reference', () => {
|
||||
const config: ProviderConfig = { apiKey: 'test' };
|
||||
const provider = new TestProvider(config);
|
||||
|
||||
const retrieved1 = provider.getConfig();
|
||||
@@ -139,31 +135,31 @@ describe("base-provider.ts", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("setConfig", () => {
|
||||
it("should merge partial config with existing config", () => {
|
||||
const provider = new TestProvider({ apiKey: "original-key" });
|
||||
describe('setConfig', () => {
|
||||
it('should merge partial config with existing config', () => {
|
||||
const provider = new TestProvider({ apiKey: 'original-key' });
|
||||
|
||||
provider.setConfig({ model: "new-model" });
|
||||
provider.setConfig({ model: 'new-model' });
|
||||
|
||||
expect(provider.getConfig()).toEqual({
|
||||
apiKey: "original-key",
|
||||
model: "new-model",
|
||||
apiKey: 'original-key',
|
||||
model: 'new-model',
|
||||
});
|
||||
});
|
||||
|
||||
it("should override existing fields", () => {
|
||||
const provider = new TestProvider({ apiKey: "old-key", model: "old-model" });
|
||||
it('should override existing fields', () => {
|
||||
const provider = new TestProvider({ apiKey: 'old-key', model: 'old-model' });
|
||||
|
||||
provider.setConfig({ apiKey: "new-key" });
|
||||
provider.setConfig({ apiKey: 'new-key' });
|
||||
|
||||
expect(provider.getConfig()).toEqual({
|
||||
apiKey: "new-key",
|
||||
model: "old-model",
|
||||
apiKey: 'new-key',
|
||||
model: 'old-model',
|
||||
});
|
||||
});
|
||||
|
||||
it("should accept empty object", () => {
|
||||
const provider = new TestProvider({ apiKey: "test" });
|
||||
it('should accept empty object', () => {
|
||||
const provider = new TestProvider({ apiKey: 'test' });
|
||||
const originalConfig = provider.getConfig();
|
||||
|
||||
provider.setConfig({});
|
||||
@@ -171,68 +167,68 @@ describe("base-provider.ts", () => {
|
||||
expect(provider.getConfig()).toEqual(originalConfig);
|
||||
});
|
||||
|
||||
it("should handle multiple updates", () => {
|
||||
it('should handle multiple updates', () => {
|
||||
const provider = new TestProvider();
|
||||
|
||||
provider.setConfig({ apiKey: "key1" });
|
||||
provider.setConfig({ model: "model1" });
|
||||
provider.setConfig({ baseUrl: "https://test.com" });
|
||||
provider.setConfig({ apiKey: 'key1' });
|
||||
provider.setConfig({ model: 'model1' });
|
||||
provider.setConfig({ baseUrl: 'https://test.com' });
|
||||
|
||||
expect(provider.getConfig()).toEqual({
|
||||
apiKey: "key1",
|
||||
model: "model1",
|
||||
baseUrl: "https://test.com",
|
||||
apiKey: 'key1',
|
||||
model: 'model1',
|
||||
baseUrl: 'https://test.com',
|
||||
});
|
||||
});
|
||||
|
||||
it("should preserve other fields when updating one field", () => {
|
||||
it('should preserve other fields when updating one field', () => {
|
||||
const provider = new TestProvider({
|
||||
apiKey: "key",
|
||||
model: "model",
|
||||
baseUrl: "https://test.com",
|
||||
apiKey: 'key',
|
||||
model: 'model',
|
||||
baseUrl: 'https://test.com',
|
||||
});
|
||||
|
||||
provider.setConfig({ model: "new-model" });
|
||||
provider.setConfig({ model: 'new-model' });
|
||||
|
||||
expect(provider.getConfig()).toEqual({
|
||||
apiKey: "key",
|
||||
model: "new-model",
|
||||
baseUrl: "https://test.com",
|
||||
apiKey: 'key',
|
||||
model: 'new-model',
|
||||
baseUrl: 'https://test.com',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("abstract methods", () => {
|
||||
it("should require getName implementation", () => {
|
||||
describe('abstract methods', () => {
|
||||
it('should require getName implementation', () => {
|
||||
const provider = new TestProvider();
|
||||
expect(typeof provider.getName).toBe("function");
|
||||
expect(provider.getName()).toBe("test-provider");
|
||||
expect(typeof provider.getName).toBe('function');
|
||||
expect(provider.getName()).toBe('test-provider');
|
||||
});
|
||||
|
||||
it("should require executeQuery implementation", async () => {
|
||||
it('should require executeQuery implementation', async () => {
|
||||
const provider = new TestProvider();
|
||||
expect(typeof provider.executeQuery).toBe("function");
|
||||
expect(typeof provider.executeQuery).toBe('function');
|
||||
|
||||
const generator = provider.executeQuery({
|
||||
prompt: "test",
|
||||
projectDirectory: "/test",
|
||||
prompt: 'test',
|
||||
projectDirectory: '/test',
|
||||
});
|
||||
const result = await generator.next();
|
||||
|
||||
expect(result.value).toEqual({ type: "text", text: "test response" });
|
||||
expect(result.value).toEqual({ type: 'text', text: 'test response' });
|
||||
});
|
||||
|
||||
it("should require detectInstallation implementation", async () => {
|
||||
it('should require detectInstallation implementation', async () => {
|
||||
const provider = new TestProvider();
|
||||
expect(typeof provider.detectInstallation).toBe("function");
|
||||
expect(typeof provider.detectInstallation).toBe('function');
|
||||
|
||||
const status = await provider.detectInstallation();
|
||||
expect(status).toHaveProperty("installed");
|
||||
expect(status).toHaveProperty('installed');
|
||||
});
|
||||
|
||||
it("should require getAvailableModels implementation", () => {
|
||||
it('should require getAvailableModels implementation', () => {
|
||||
const provider = new TestProvider();
|
||||
expect(typeof provider.getAvailableModels).toBe("function");
|
||||
expect(typeof provider.getAvailableModels).toBe('function');
|
||||
|
||||
const models = provider.getAvailableModels();
|
||||
expect(Array.isArray(models)).toBe(true);
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { ClaudeProvider } from "@/providers/claude-provider.js";
|
||||
import * as sdk from "@anthropic-ai/claude-agent-sdk";
|
||||
import { collectAsyncGenerator } from "../../utils/helpers.js";
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { ClaudeProvider } from '@/providers/claude-provider.js';
|
||||
import * as sdk from '@anthropic-ai/claude-agent-sdk';
|
||||
import { collectAsyncGenerator } from '../../utils/helpers.js';
|
||||
|
||||
vi.mock("@anthropic-ai/claude-agent-sdk");
|
||||
vi.mock('@anthropic-ai/claude-agent-sdk');
|
||||
|
||||
describe("claude-provider.ts", () => {
|
||||
describe('claude-provider.ts', () => {
|
||||
let provider: ClaudeProvider;
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -14,17 +14,17 @@ describe("claude-provider.ts", () => {
|
||||
delete process.env.ANTHROPIC_API_KEY;
|
||||
});
|
||||
|
||||
describe("getName", () => {
|
||||
describe('getName', () => {
|
||||
it("should return 'claude' as provider name", () => {
|
||||
expect(provider.getName()).toBe("claude");
|
||||
expect(provider.getName()).toBe('claude');
|
||||
});
|
||||
});
|
||||
|
||||
describe("executeQuery", () => {
|
||||
it("should execute simple text query", async () => {
|
||||
describe('executeQuery', () => {
|
||||
it('should execute simple text query', async () => {
|
||||
const mockMessages = [
|
||||
{ type: "text", text: "Response 1" },
|
||||
{ type: "text", text: "Response 2" },
|
||||
{ type: 'text', text: 'Response 1' },
|
||||
{ type: 'text', text: 'Response 2' },
|
||||
];
|
||||
|
||||
vi.mocked(sdk.query).mockReturnValue(
|
||||
@@ -36,95 +36,86 @@ describe("claude-provider.ts", () => {
|
||||
);
|
||||
|
||||
const generator = provider.executeQuery({
|
||||
prompt: "Hello",
|
||||
cwd: "/test",
|
||||
prompt: 'Hello',
|
||||
cwd: '/test',
|
||||
});
|
||||
|
||||
const results = await collectAsyncGenerator(generator);
|
||||
|
||||
expect(results).toHaveLength(2);
|
||||
expect(results[0]).toEqual({ type: "text", text: "Response 1" });
|
||||
expect(results[1]).toEqual({ type: "text", text: "Response 2" });
|
||||
expect(results[0]).toEqual({ type: 'text', text: 'Response 1' });
|
||||
expect(results[1]).toEqual({ type: 'text', text: 'Response 2' });
|
||||
});
|
||||
|
||||
it("should pass correct options to SDK", async () => {
|
||||
it('should pass correct options to SDK', async () => {
|
||||
vi.mocked(sdk.query).mockReturnValue(
|
||||
(async function* () {
|
||||
yield { type: "text", text: "test" };
|
||||
yield { type: 'text', text: 'test' };
|
||||
})()
|
||||
);
|
||||
|
||||
const generator = provider.executeQuery({
|
||||
prompt: "Test prompt",
|
||||
model: "claude-opus-4-5-20251101",
|
||||
cwd: "/test/dir",
|
||||
systemPrompt: "You are helpful",
|
||||
prompt: 'Test prompt',
|
||||
model: 'claude-opus-4-5-20251101',
|
||||
cwd: '/test/dir',
|
||||
systemPrompt: 'You are helpful',
|
||||
maxTurns: 10,
|
||||
allowedTools: ["Read", "Write"],
|
||||
allowedTools: ['Read', 'Write'],
|
||||
});
|
||||
|
||||
await collectAsyncGenerator(generator);
|
||||
|
||||
expect(sdk.query).toHaveBeenCalledWith({
|
||||
prompt: "Test prompt",
|
||||
prompt: 'Test prompt',
|
||||
options: expect.objectContaining({
|
||||
model: "claude-opus-4-5-20251101",
|
||||
systemPrompt: "You are helpful",
|
||||
model: 'claude-opus-4-5-20251101',
|
||||
systemPrompt: 'You are helpful',
|
||||
maxTurns: 10,
|
||||
cwd: "/test/dir",
|
||||
allowedTools: ["Read", "Write"],
|
||||
permissionMode: "acceptEdits",
|
||||
cwd: '/test/dir',
|
||||
allowedTools: ['Read', 'Write'],
|
||||
permissionMode: 'acceptEdits',
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it("should use default allowed tools when not specified", async () => {
|
||||
it('should use default allowed tools when not specified', async () => {
|
||||
vi.mocked(sdk.query).mockReturnValue(
|
||||
(async function* () {
|
||||
yield { type: "text", text: "test" };
|
||||
yield { type: 'text', text: 'test' };
|
||||
})()
|
||||
);
|
||||
|
||||
const generator = provider.executeQuery({
|
||||
prompt: "Test",
|
||||
cwd: "/test",
|
||||
prompt: 'Test',
|
||||
cwd: '/test',
|
||||
});
|
||||
|
||||
await collectAsyncGenerator(generator);
|
||||
|
||||
expect(sdk.query).toHaveBeenCalledWith({
|
||||
prompt: "Test",
|
||||
prompt: 'Test',
|
||||
options: expect.objectContaining({
|
||||
allowedTools: [
|
||||
"Read",
|
||||
"Write",
|
||||
"Edit",
|
||||
"Glob",
|
||||
"Grep",
|
||||
"Bash",
|
||||
"WebSearch",
|
||||
"WebFetch",
|
||||
],
|
||||
allowedTools: ['Read', 'Write', 'Edit', 'Glob', 'Grep', 'Bash', 'WebSearch', 'WebFetch'],
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it("should enable sandbox by default", async () => {
|
||||
it('should enable sandbox by default', async () => {
|
||||
vi.mocked(sdk.query).mockReturnValue(
|
||||
(async function* () {
|
||||
yield { type: "text", text: "test" };
|
||||
yield { type: 'text', text: 'test' };
|
||||
})()
|
||||
);
|
||||
|
||||
const generator = provider.executeQuery({
|
||||
prompt: "Test",
|
||||
cwd: "/test",
|
||||
prompt: 'Test',
|
||||
cwd: '/test',
|
||||
});
|
||||
|
||||
await collectAsyncGenerator(generator);
|
||||
|
||||
expect(sdk.query).toHaveBeenCalledWith({
|
||||
prompt: "Test",
|
||||
prompt: 'Test',
|
||||
options: expect.objectContaining({
|
||||
sandbox: {
|
||||
enabled: true,
|
||||
@@ -134,118 +125,142 @@ describe("claude-provider.ts", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("should pass abortController if provided", async () => {
|
||||
it('should pass abortController if provided', async () => {
|
||||
vi.mocked(sdk.query).mockReturnValue(
|
||||
(async function* () {
|
||||
yield { type: "text", text: "test" };
|
||||
yield { type: 'text', text: 'test' };
|
||||
})()
|
||||
);
|
||||
|
||||
const abortController = new AbortController();
|
||||
|
||||
const generator = provider.executeQuery({
|
||||
prompt: "Test",
|
||||
cwd: "/test",
|
||||
prompt: 'Test',
|
||||
cwd: '/test',
|
||||
abortController,
|
||||
});
|
||||
|
||||
await collectAsyncGenerator(generator);
|
||||
|
||||
expect(sdk.query).toHaveBeenCalledWith({
|
||||
prompt: "Test",
|
||||
prompt: 'Test',
|
||||
options: expect.objectContaining({
|
||||
abortController,
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it("should handle conversation history with sdkSessionId using resume option", async () => {
|
||||
it('should handle conversation history with sdkSessionId using resume option', async () => {
|
||||
vi.mocked(sdk.query).mockReturnValue(
|
||||
(async function* () {
|
||||
yield { type: "text", text: "test" };
|
||||
yield { type: 'text', text: 'test' };
|
||||
})()
|
||||
);
|
||||
|
||||
const conversationHistory = [
|
||||
{ role: "user" as const, content: "Previous message" },
|
||||
{ role: "assistant" as const, content: "Previous response" },
|
||||
{ role: 'user' as const, content: 'Previous message' },
|
||||
{ role: 'assistant' as const, content: 'Previous response' },
|
||||
];
|
||||
|
||||
const generator = provider.executeQuery({
|
||||
prompt: "Current message",
|
||||
cwd: "/test",
|
||||
prompt: 'Current message',
|
||||
cwd: '/test',
|
||||
conversationHistory,
|
||||
sdkSessionId: "test-session-id",
|
||||
sdkSessionId: 'test-session-id',
|
||||
});
|
||||
|
||||
await collectAsyncGenerator(generator);
|
||||
|
||||
// Should use resume option when sdkSessionId is provided with history
|
||||
expect(sdk.query).toHaveBeenCalledWith({
|
||||
prompt: "Current message",
|
||||
prompt: 'Current message',
|
||||
options: expect.objectContaining({
|
||||
resume: "test-session-id",
|
||||
resume: 'test-session-id',
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it("should handle array prompt (with images)", async () => {
|
||||
it('should handle array prompt (with images)', async () => {
|
||||
vi.mocked(sdk.query).mockReturnValue(
|
||||
(async function* () {
|
||||
yield { type: "text", text: "test" };
|
||||
yield { type: 'text', text: 'test' };
|
||||
})()
|
||||
);
|
||||
|
||||
const arrayPrompt = [
|
||||
{ type: "text", text: "Describe this" },
|
||||
{ type: "image", source: { type: "base64", data: "..." } },
|
||||
{ type: 'text', text: 'Describe this' },
|
||||
{ type: 'image', source: { type: 'base64', data: '...' } },
|
||||
];
|
||||
|
||||
const generator = provider.executeQuery({
|
||||
prompt: arrayPrompt as any,
|
||||
cwd: "/test",
|
||||
cwd: '/test',
|
||||
});
|
||||
|
||||
await collectAsyncGenerator(generator);
|
||||
|
||||
// Should pass an async generator as prompt for array inputs
|
||||
const callArgs = vi.mocked(sdk.query).mock.calls[0][0];
|
||||
expect(typeof callArgs.prompt).not.toBe("string");
|
||||
expect(typeof callArgs.prompt).not.toBe('string');
|
||||
});
|
||||
|
||||
it("should use maxTurns default of 20", async () => {
|
||||
it('should use maxTurns default of 20', async () => {
|
||||
vi.mocked(sdk.query).mockReturnValue(
|
||||
(async function* () {
|
||||
yield { type: "text", text: "test" };
|
||||
yield { type: 'text', text: 'test' };
|
||||
})()
|
||||
);
|
||||
|
||||
const generator = provider.executeQuery({
|
||||
prompt: "Test",
|
||||
cwd: "/test",
|
||||
prompt: 'Test',
|
||||
cwd: '/test',
|
||||
});
|
||||
|
||||
await collectAsyncGenerator(generator);
|
||||
|
||||
expect(sdk.query).toHaveBeenCalledWith({
|
||||
prompt: "Test",
|
||||
prompt: 'Test',
|
||||
options: expect.objectContaining({
|
||||
maxTurns: 20,
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle errors during execution and rethrow', async () => {
|
||||
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
const testError = new Error('SDK execution failed');
|
||||
|
||||
vi.mocked(sdk.query).mockReturnValue(
|
||||
(async function* () {
|
||||
throw testError;
|
||||
})()
|
||||
);
|
||||
|
||||
const generator = provider.executeQuery({
|
||||
prompt: 'Test',
|
||||
cwd: '/test',
|
||||
});
|
||||
|
||||
await expect(collectAsyncGenerator(generator)).rejects.toThrow('SDK execution failed');
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
'[ClaudeProvider] executeQuery() error during execution:',
|
||||
testError
|
||||
);
|
||||
|
||||
consoleErrorSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe("detectInstallation", () => {
|
||||
it("should return installed with SDK method", async () => {
|
||||
describe('detectInstallation', () => {
|
||||
it('should return installed with SDK method', async () => {
|
||||
const result = await provider.detectInstallation();
|
||||
|
||||
expect(result.installed).toBe(true);
|
||||
expect(result.method).toBe("sdk");
|
||||
expect(result.method).toBe('sdk');
|
||||
});
|
||||
|
||||
it("should detect ANTHROPIC_API_KEY", async () => {
|
||||
process.env.ANTHROPIC_API_KEY = "test-key";
|
||||
it('should detect ANTHROPIC_API_KEY', async () => {
|
||||
process.env.ANTHROPIC_API_KEY = 'test-key';
|
||||
|
||||
const result = await provider.detectInstallation();
|
||||
|
||||
@@ -253,7 +268,7 @@ describe("claude-provider.ts", () => {
|
||||
expect(result.authenticated).toBe(true);
|
||||
});
|
||||
|
||||
it("should return hasApiKey false when no keys present", async () => {
|
||||
it('should return hasApiKey false when no keys present', async () => {
|
||||
const result = await provider.detectInstallation();
|
||||
|
||||
expect(result.hasApiKey).toBe(false);
|
||||
@@ -261,54 +276,52 @@ describe("claude-provider.ts", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("getAvailableModels", () => {
|
||||
it("should return 4 Claude models", () => {
|
||||
describe('getAvailableModels', () => {
|
||||
it('should return 4 Claude models', () => {
|
||||
const models = provider.getAvailableModels();
|
||||
|
||||
expect(models).toHaveLength(4);
|
||||
});
|
||||
|
||||
it("should include Claude Opus 4.5", () => {
|
||||
it('should include Claude Opus 4.5', () => {
|
||||
const models = provider.getAvailableModels();
|
||||
|
||||
const opus = models.find((m) => m.id === "claude-opus-4-5-20251101");
|
||||
const opus = models.find((m) => m.id === 'claude-opus-4-5-20251101');
|
||||
expect(opus).toBeDefined();
|
||||
expect(opus?.name).toBe("Claude Opus 4.5");
|
||||
expect(opus?.provider).toBe("anthropic");
|
||||
expect(opus?.name).toBe('Claude Opus 4.5');
|
||||
expect(opus?.provider).toBe('anthropic');
|
||||
});
|
||||
|
||||
it("should include Claude Sonnet 4", () => {
|
||||
it('should include Claude Sonnet 4', () => {
|
||||
const models = provider.getAvailableModels();
|
||||
|
||||
const sonnet = models.find((m) => m.id === "claude-sonnet-4-20250514");
|
||||
const sonnet = models.find((m) => m.id === 'claude-sonnet-4-20250514');
|
||||
expect(sonnet).toBeDefined();
|
||||
expect(sonnet?.name).toBe("Claude Sonnet 4");
|
||||
expect(sonnet?.name).toBe('Claude Sonnet 4');
|
||||
});
|
||||
|
||||
it("should include Claude 3.5 Sonnet", () => {
|
||||
it('should include Claude 3.5 Sonnet', () => {
|
||||
const models = provider.getAvailableModels();
|
||||
|
||||
const sonnet35 = models.find(
|
||||
(m) => m.id === "claude-3-5-sonnet-20241022"
|
||||
);
|
||||
const sonnet35 = models.find((m) => m.id === 'claude-3-5-sonnet-20241022');
|
||||
expect(sonnet35).toBeDefined();
|
||||
});
|
||||
|
||||
it("should include Claude 3.5 Haiku", () => {
|
||||
it('should include Claude 3.5 Haiku', () => {
|
||||
const models = provider.getAvailableModels();
|
||||
|
||||
const haiku = models.find((m) => m.id === "claude-3-5-haiku-20241022");
|
||||
const haiku = models.find((m) => m.id === 'claude-3-5-haiku-20241022');
|
||||
expect(haiku).toBeDefined();
|
||||
});
|
||||
|
||||
it("should mark Opus as default", () => {
|
||||
it('should mark Opus as default', () => {
|
||||
const models = provider.getAvailableModels();
|
||||
|
||||
const opus = models.find((m) => m.id === "claude-opus-4-5-20251101");
|
||||
const opus = models.find((m) => m.id === 'claude-opus-4-5-20251101');
|
||||
expect(opus?.default).toBe(true);
|
||||
});
|
||||
|
||||
it("should all support vision and tools", () => {
|
||||
it('should all support vision and tools', () => {
|
||||
const models = provider.getAvailableModels();
|
||||
|
||||
models.forEach((model) => {
|
||||
@@ -317,7 +330,7 @@ describe("claude-provider.ts", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("should have correct context windows", () => {
|
||||
it('should have correct context windows', () => {
|
||||
const models = provider.getAvailableModels();
|
||||
|
||||
models.forEach((model) => {
|
||||
@@ -325,7 +338,7 @@ describe("claude-provider.ts", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("should have modelString field matching id", () => {
|
||||
it('should have modelString field matching id', () => {
|
||||
const models = provider.getAvailableModels();
|
||||
|
||||
models.forEach((model) => {
|
||||
@@ -334,38 +347,38 @@ describe("claude-provider.ts", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("supportsFeature", () => {
|
||||
describe('supportsFeature', () => {
|
||||
it("should support 'tools' feature", () => {
|
||||
expect(provider.supportsFeature("tools")).toBe(true);
|
||||
expect(provider.supportsFeature('tools')).toBe(true);
|
||||
});
|
||||
|
||||
it("should support 'text' feature", () => {
|
||||
expect(provider.supportsFeature("text")).toBe(true);
|
||||
expect(provider.supportsFeature('text')).toBe(true);
|
||||
});
|
||||
|
||||
it("should support 'vision' feature", () => {
|
||||
expect(provider.supportsFeature("vision")).toBe(true);
|
||||
expect(provider.supportsFeature('vision')).toBe(true);
|
||||
});
|
||||
|
||||
it("should support 'thinking' feature", () => {
|
||||
expect(provider.supportsFeature("thinking")).toBe(true);
|
||||
expect(provider.supportsFeature('thinking')).toBe(true);
|
||||
});
|
||||
|
||||
it("should not support 'mcp' feature", () => {
|
||||
expect(provider.supportsFeature("mcp")).toBe(false);
|
||||
expect(provider.supportsFeature('mcp')).toBe(false);
|
||||
});
|
||||
|
||||
it("should not support 'cli' feature", () => {
|
||||
expect(provider.supportsFeature("cli")).toBe(false);
|
||||
expect(provider.supportsFeature('cli')).toBe(false);
|
||||
});
|
||||
|
||||
it("should not support unknown features", () => {
|
||||
expect(provider.supportsFeature("unknown")).toBe(false);
|
||||
it('should not support unknown features', () => {
|
||||
expect(provider.supportsFeature('unknown')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("validateConfig", () => {
|
||||
it("should validate config from base class", () => {
|
||||
describe('validateConfig', () => {
|
||||
it('should validate config from base class', () => {
|
||||
const result = provider.validateConfig();
|
||||
|
||||
expect(result.valid).toBe(true);
|
||||
@@ -373,21 +386,21 @@ describe("claude-provider.ts", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("config management", () => {
|
||||
it("should get and set config", () => {
|
||||
provider.setConfig({ apiKey: "test-key" });
|
||||
describe('config management', () => {
|
||||
it('should get and set config', () => {
|
||||
provider.setConfig({ apiKey: 'test-key' });
|
||||
|
||||
const config = provider.getConfig();
|
||||
expect(config.apiKey).toBe("test-key");
|
||||
expect(config.apiKey).toBe('test-key');
|
||||
});
|
||||
|
||||
it("should merge config updates", () => {
|
||||
provider.setConfig({ apiKey: "key1" });
|
||||
provider.setConfig({ model: "model1" });
|
||||
it('should merge config updates', () => {
|
||||
provider.setConfig({ apiKey: 'key1' });
|
||||
provider.setConfig({ model: 'model1' });
|
||||
|
||||
const config = provider.getConfig();
|
||||
expect(config.apiKey).toBe("key1");
|
||||
expect(config.model).toBe("model1");
|
||||
expect(config.apiKey).toBe('key1');
|
||||
expect(config.model).toBe('model1');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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