Merge main into massive-terminal-upgrade

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

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

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

View File

@@ -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' }],
},
};

View File

@@ -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(() => {

View File

@@ -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);
});
});
});

View File

@@ -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);

View File

@@ -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');
});
});
});

View File

@@ -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');
});
});
});

View File

@@ -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();
});

View File

@@ -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');
});
});
});

View File

@@ -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);
});
});
});

View File

@@ -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');
});
});
});

View File

@@ -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');
});
});
});

View File

@@ -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);
});
});
});

View File

@@ -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'));
});
});
});

View File

@@ -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);
});
});
});

View File

@@ -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();
});
});
});

View File

@@ -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);

View File

@@ -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');
});
});
});

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,611 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import fs from 'fs/promises';
import path from 'path';
import os from 'os';
import { SettingsService } from '@/services/settings-service.js';
import {
DEFAULT_GLOBAL_SETTINGS,
DEFAULT_CREDENTIALS,
DEFAULT_PROJECT_SETTINGS,
SETTINGS_VERSION,
CREDENTIALS_VERSION,
PROJECT_SETTINGS_VERSION,
type GlobalSettings,
type Credentials,
type ProjectSettings,
} from '@/types/settings.js';
describe('settings-service.ts', () => {
let testDataDir: string;
let testProjectDir: string;
let settingsService: SettingsService;
beforeEach(async () => {
testDataDir = path.join(os.tmpdir(), `settings-test-${Date.now()}`);
testProjectDir = path.join(os.tmpdir(), `project-test-${Date.now()}`);
await fs.mkdir(testDataDir, { recursive: true });
await fs.mkdir(testProjectDir, { recursive: true });
settingsService = new SettingsService(testDataDir);
});
afterEach(async () => {
try {
await fs.rm(testDataDir, { recursive: true, force: true });
await fs.rm(testProjectDir, { recursive: true, force: true });
} catch {
// Ignore cleanup errors
}
});
describe('getGlobalSettings', () => {
it('should return default settings when file does not exist', async () => {
const settings = await settingsService.getGlobalSettings();
expect(settings).toEqual(DEFAULT_GLOBAL_SETTINGS);
});
it('should read and return existing settings', async () => {
const customSettings: GlobalSettings = {
...DEFAULT_GLOBAL_SETTINGS,
theme: 'light',
sidebarOpen: false,
maxConcurrency: 5,
};
const settingsPath = path.join(testDataDir, 'settings.json');
await fs.writeFile(settingsPath, JSON.stringify(customSettings, null, 2));
const settings = await settingsService.getGlobalSettings();
expect(settings.theme).toBe('light');
expect(settings.sidebarOpen).toBe(false);
expect(settings.maxConcurrency).toBe(5);
});
it('should merge with defaults for missing properties', async () => {
const partialSettings = {
version: SETTINGS_VERSION,
theme: 'dark',
};
const settingsPath = path.join(testDataDir, 'settings.json');
await fs.writeFile(settingsPath, JSON.stringify(partialSettings, null, 2));
const settings = await settingsService.getGlobalSettings();
expect(settings.theme).toBe('dark');
expect(settings.sidebarOpen).toBe(DEFAULT_GLOBAL_SETTINGS.sidebarOpen);
expect(settings.maxConcurrency).toBe(DEFAULT_GLOBAL_SETTINGS.maxConcurrency);
});
it('should merge keyboard shortcuts deeply', async () => {
const customSettings: GlobalSettings = {
...DEFAULT_GLOBAL_SETTINGS,
keyboardShortcuts: {
...DEFAULT_GLOBAL_SETTINGS.keyboardShortcuts,
board: 'B',
},
};
const settingsPath = path.join(testDataDir, 'settings.json');
await fs.writeFile(settingsPath, JSON.stringify(customSettings, null, 2));
const settings = await settingsService.getGlobalSettings();
expect(settings.keyboardShortcuts.board).toBe('B');
expect(settings.keyboardShortcuts.agent).toBe(
DEFAULT_GLOBAL_SETTINGS.keyboardShortcuts.agent
);
});
});
describe('updateGlobalSettings', () => {
it('should create settings file with updates', async () => {
const updates: Partial<GlobalSettings> = {
theme: 'light',
sidebarOpen: false,
};
const updated = await settingsService.updateGlobalSettings(updates);
expect(updated.theme).toBe('light');
expect(updated.sidebarOpen).toBe(false);
expect(updated.version).toBe(SETTINGS_VERSION);
const settingsPath = path.join(testDataDir, 'settings.json');
const fileContent = await fs.readFile(settingsPath, 'utf-8');
const saved = JSON.parse(fileContent);
expect(saved.theme).toBe('light');
expect(saved.sidebarOpen).toBe(false);
});
it('should merge updates with existing settings', async () => {
const initial: GlobalSettings = {
...DEFAULT_GLOBAL_SETTINGS,
theme: 'dark',
maxConcurrency: 3,
};
const settingsPath = path.join(testDataDir, 'settings.json');
await fs.writeFile(settingsPath, JSON.stringify(initial, null, 2));
const updates: Partial<GlobalSettings> = {
theme: 'light',
};
const updated = await settingsService.updateGlobalSettings(updates);
expect(updated.theme).toBe('light');
expect(updated.maxConcurrency).toBe(3); // Preserved from initial
});
it('should deep merge keyboard shortcuts', async () => {
const updates: Partial<GlobalSettings> = {
keyboardShortcuts: {
board: 'B',
},
};
const updated = await settingsService.updateGlobalSettings(updates);
expect(updated.keyboardShortcuts.board).toBe('B');
expect(updated.keyboardShortcuts.agent).toBe(DEFAULT_GLOBAL_SETTINGS.keyboardShortcuts.agent);
});
it('should create data directory if it does not exist', async () => {
const newDataDir = path.join(os.tmpdir(), `new-data-dir-${Date.now()}`);
const newService = new SettingsService(newDataDir);
await newService.updateGlobalSettings({ theme: 'light' });
const stats = await fs.stat(newDataDir);
expect(stats.isDirectory()).toBe(true);
await fs.rm(newDataDir, { recursive: true, force: true });
});
});
describe('hasGlobalSettings', () => {
it('should return false when settings file does not exist', async () => {
const exists = await settingsService.hasGlobalSettings();
expect(exists).toBe(false);
});
it('should return true when settings file exists', async () => {
await settingsService.updateGlobalSettings({ theme: 'light' });
const exists = await settingsService.hasGlobalSettings();
expect(exists).toBe(true);
});
});
describe('getCredentials', () => {
it('should return default credentials when file does not exist', async () => {
const credentials = await settingsService.getCredentials();
expect(credentials).toEqual(DEFAULT_CREDENTIALS);
});
it('should read and return existing credentials', async () => {
const customCredentials: Credentials = {
...DEFAULT_CREDENTIALS,
apiKeys: {
anthropic: 'sk-test-key',
},
};
const credentialsPath = path.join(testDataDir, 'credentials.json');
await fs.writeFile(credentialsPath, JSON.stringify(customCredentials, null, 2));
const credentials = await settingsService.getCredentials();
expect(credentials.apiKeys.anthropic).toBe('sk-test-key');
});
it('should merge with defaults for missing api keys', async () => {
const partialCredentials = {
version: CREDENTIALS_VERSION,
apiKeys: {
anthropic: 'sk-test',
},
};
const credentialsPath = path.join(testDataDir, 'credentials.json');
await fs.writeFile(credentialsPath, JSON.stringify(partialCredentials, null, 2));
const credentials = await settingsService.getCredentials();
expect(credentials.apiKeys.anthropic).toBe('sk-test');
});
});
describe('updateCredentials', () => {
it('should create credentials file with updates', async () => {
const updates: Partial<Credentials> = {
apiKeys: {
anthropic: 'sk-test-key',
},
};
const updated = await settingsService.updateCredentials(updates);
expect(updated.apiKeys.anthropic).toBe('sk-test-key');
expect(updated.version).toBe(CREDENTIALS_VERSION);
const credentialsPath = path.join(testDataDir, 'credentials.json');
const fileContent = await fs.readFile(credentialsPath, 'utf-8');
const saved = JSON.parse(fileContent);
expect(saved.apiKeys.anthropic).toBe('sk-test-key');
});
it('should merge updates with existing credentials', async () => {
const initial: Credentials = {
...DEFAULT_CREDENTIALS,
apiKeys: {
anthropic: 'sk-initial',
},
};
const credentialsPath = path.join(testDataDir, 'credentials.json');
await fs.writeFile(credentialsPath, JSON.stringify(initial, null, 2));
const updates: Partial<Credentials> = {
apiKeys: {
anthropic: 'sk-updated',
},
};
const updated = await settingsService.updateCredentials(updates);
expect(updated.apiKeys.anthropic).toBe('sk-updated');
});
it('should deep merge api keys', async () => {
const initial: Credentials = {
...DEFAULT_CREDENTIALS,
apiKeys: {
anthropic: 'sk-anthropic',
},
};
const credentialsPath = path.join(testDataDir, 'credentials.json');
await fs.writeFile(credentialsPath, JSON.stringify(initial, null, 2));
const updates: Partial<Credentials> = {
apiKeys: {
anthropic: 'sk-updated-anthropic',
},
};
const updated = await settingsService.updateCredentials(updates);
expect(updated.apiKeys.anthropic).toBe('sk-updated-anthropic');
});
});
describe('getMaskedCredentials', () => {
it('should return masked credentials for empty keys', async () => {
const masked = await settingsService.getMaskedCredentials();
expect(masked.anthropic.configured).toBe(false);
expect(masked.anthropic.masked).toBe('');
});
it('should mask keys correctly', async () => {
await settingsService.updateCredentials({
apiKeys: {
anthropic: 'sk-ant-api03-1234567890abcdef',
},
});
const masked = await settingsService.getMaskedCredentials();
expect(masked.anthropic.configured).toBe(true);
expect(masked.anthropic.masked).toBe('sk-a...cdef');
});
it('should handle short keys', async () => {
await settingsService.updateCredentials({
apiKeys: {
anthropic: 'short',
},
});
const masked = await settingsService.getMaskedCredentials();
expect(masked.anthropic.configured).toBe(true);
expect(masked.anthropic.masked).toBe('');
});
});
describe('hasCredentials', () => {
it('should return false when credentials file does not exist', async () => {
const exists = await settingsService.hasCredentials();
expect(exists).toBe(false);
});
it('should return true when credentials file exists', async () => {
await settingsService.updateCredentials({
apiKeys: { anthropic: 'test' },
});
const exists = await settingsService.hasCredentials();
expect(exists).toBe(true);
});
});
describe('getProjectSettings', () => {
it('should return default settings when file does not exist', async () => {
const settings = await settingsService.getProjectSettings(testProjectDir);
expect(settings).toEqual(DEFAULT_PROJECT_SETTINGS);
});
it('should read and return existing project settings', async () => {
const customSettings: ProjectSettings = {
...DEFAULT_PROJECT_SETTINGS,
theme: 'light',
useWorktrees: true,
};
const automakerDir = path.join(testProjectDir, '.automaker');
await fs.mkdir(automakerDir, { recursive: true });
const settingsPath = path.join(automakerDir, 'settings.json');
await fs.writeFile(settingsPath, JSON.stringify(customSettings, null, 2));
const settings = await settingsService.getProjectSettings(testProjectDir);
expect(settings.theme).toBe('light');
expect(settings.useWorktrees).toBe(true);
});
it('should merge with defaults for missing properties', async () => {
const partialSettings = {
version: PROJECT_SETTINGS_VERSION,
theme: 'dark',
};
const automakerDir = path.join(testProjectDir, '.automaker');
await fs.mkdir(automakerDir, { recursive: true });
const settingsPath = path.join(automakerDir, 'settings.json');
await fs.writeFile(settingsPath, JSON.stringify(partialSettings, null, 2));
const settings = await settingsService.getProjectSettings(testProjectDir);
expect(settings.theme).toBe('dark');
expect(settings.version).toBe(PROJECT_SETTINGS_VERSION);
});
});
describe('updateProjectSettings', () => {
it('should create project settings file with updates', async () => {
const updates: Partial<ProjectSettings> = {
theme: 'light',
useWorktrees: true,
};
const updated = await settingsService.updateProjectSettings(testProjectDir, updates);
expect(updated.theme).toBe('light');
expect(updated.useWorktrees).toBe(true);
expect(updated.version).toBe(PROJECT_SETTINGS_VERSION);
const automakerDir = path.join(testProjectDir, '.automaker');
const settingsPath = path.join(automakerDir, 'settings.json');
const fileContent = await fs.readFile(settingsPath, 'utf-8');
const saved = JSON.parse(fileContent);
expect(saved.theme).toBe('light');
expect(saved.useWorktrees).toBe(true);
});
it('should merge updates with existing project settings', async () => {
const initial: ProjectSettings = {
...DEFAULT_PROJECT_SETTINGS,
theme: 'dark',
useWorktrees: false,
};
const automakerDir = path.join(testProjectDir, '.automaker');
await fs.mkdir(automakerDir, { recursive: true });
const settingsPath = path.join(automakerDir, 'settings.json');
await fs.writeFile(settingsPath, JSON.stringify(initial, null, 2));
const updates: Partial<ProjectSettings> = {
theme: 'light',
};
const updated = await settingsService.updateProjectSettings(testProjectDir, updates);
expect(updated.theme).toBe('light');
expect(updated.useWorktrees).toBe(false); // Preserved
});
it('should deep merge board background', async () => {
const initial: ProjectSettings = {
...DEFAULT_PROJECT_SETTINGS,
boardBackground: {
imagePath: '/path/to/image.jpg',
cardOpacity: 0.8,
columnOpacity: 0.9,
columnBorderEnabled: true,
cardGlassmorphism: false,
cardBorderEnabled: true,
cardBorderOpacity: 0.5,
hideScrollbar: false,
},
};
const automakerDir = path.join(testProjectDir, '.automaker');
await fs.mkdir(automakerDir, { recursive: true });
const settingsPath = path.join(automakerDir, 'settings.json');
await fs.writeFile(settingsPath, JSON.stringify(initial, null, 2));
const updates: Partial<ProjectSettings> = {
boardBackground: {
cardOpacity: 0.9,
},
};
const updated = await settingsService.updateProjectSettings(testProjectDir, updates);
expect(updated.boardBackground?.imagePath).toBe('/path/to/image.jpg');
expect(updated.boardBackground?.cardOpacity).toBe(0.9);
expect(updated.boardBackground?.columnOpacity).toBe(0.9);
});
it('should create .automaker directory if it does not exist', async () => {
const newProjectDir = path.join(os.tmpdir(), `new-project-${Date.now()}`);
await settingsService.updateProjectSettings(newProjectDir, { theme: 'light' });
const automakerDir = path.join(newProjectDir, '.automaker');
const stats = await fs.stat(automakerDir);
expect(stats.isDirectory()).toBe(true);
await fs.rm(newProjectDir, { recursive: true, force: true });
});
});
describe('hasProjectSettings', () => {
it('should return false when project settings file does not exist', async () => {
const exists = await settingsService.hasProjectSettings(testProjectDir);
expect(exists).toBe(false);
});
it('should return true when project settings file exists', async () => {
await settingsService.updateProjectSettings(testProjectDir, { theme: 'light' });
const exists = await settingsService.hasProjectSettings(testProjectDir);
expect(exists).toBe(true);
});
});
describe('migrateFromLocalStorage', () => {
it('should migrate global settings from localStorage data', async () => {
const localStorageData = {
'automaker-storage': JSON.stringify({
state: {
theme: 'light',
sidebarOpen: false,
maxConcurrency: 5,
},
}),
};
const result = await settingsService.migrateFromLocalStorage(localStorageData);
expect(result.success).toBe(true);
expect(result.migratedGlobalSettings).toBe(true);
expect(result.migratedCredentials).toBe(false);
expect(result.migratedProjectCount).toBe(0);
const settings = await settingsService.getGlobalSettings();
expect(settings.theme).toBe('light');
expect(settings.sidebarOpen).toBe(false);
expect(settings.maxConcurrency).toBe(5);
});
it('should migrate credentials from localStorage data', async () => {
const localStorageData = {
'automaker-storage': JSON.stringify({
state: {
apiKeys: {
anthropic: 'sk-test-key',
},
},
}),
};
const result = await settingsService.migrateFromLocalStorage(localStorageData);
expect(result.success).toBe(true);
expect(result.migratedCredentials).toBe(true);
const credentials = await settingsService.getCredentials();
expect(credentials.apiKeys.anthropic).toBe('sk-test-key');
});
it('should migrate project settings from localStorage data', async () => {
const localStorageData = {
'automaker-storage': JSON.stringify({
state: {
projects: [
{
id: 'proj1',
name: 'Project 1',
path: testProjectDir,
theme: 'light',
},
],
boardBackgroundByProject: {
[testProjectDir]: {
imagePath: '/path/to/image.jpg',
cardOpacity: 0.8,
columnOpacity: 0.9,
columnBorderEnabled: true,
cardGlassmorphism: false,
cardBorderEnabled: true,
cardBorderOpacity: 0.5,
hideScrollbar: false,
},
},
},
}),
};
const result = await settingsService.migrateFromLocalStorage(localStorageData);
expect(result.success).toBe(true);
expect(result.migratedProjectCount).toBe(1);
const projectSettings = await settingsService.getProjectSettings(testProjectDir);
expect(projectSettings.theme).toBe('light');
expect(projectSettings.boardBackground?.imagePath).toBe('/path/to/image.jpg');
});
it('should handle direct localStorage values', async () => {
const localStorageData = {
'automaker:lastProjectDir': '/path/to/project',
'file-browser-recent-folders': JSON.stringify(['/path1', '/path2']),
'worktree-panel-collapsed': 'true',
};
const result = await settingsService.migrateFromLocalStorage(localStorageData);
expect(result.success).toBe(true);
const settings = await settingsService.getGlobalSettings();
expect(settings.lastProjectDir).toBe('/path/to/project');
expect(settings.recentFolders).toEqual(['/path1', '/path2']);
expect(settings.worktreePanelCollapsed).toBe(true);
});
it('should handle invalid JSON gracefully', async () => {
const localStorageData = {
'automaker-storage': 'invalid json',
'file-browser-recent-folders': 'invalid json',
};
const result = await settingsService.migrateFromLocalStorage(localStorageData);
expect(result.success).toBe(false);
expect(result.errors.length).toBeGreaterThan(0);
});
it('should handle migration errors gracefully', async () => {
// Create a read-only directory to cause write errors
const readOnlyDir = path.join(os.tmpdir(), `readonly-${Date.now()}`);
await fs.mkdir(readOnlyDir, { recursive: true });
await fs.chmod(readOnlyDir, 0o444);
const readOnlyService = new SettingsService(readOnlyDir);
const localStorageData = {
'automaker-storage': JSON.stringify({
state: { theme: 'light' },
}),
};
const result = await readOnlyService.migrateFromLocalStorage(localStorageData);
expect(result.success).toBe(false);
expect(result.errors.length).toBeGreaterThan(0);
await fs.chmod(readOnlyDir, 0o755);
await fs.rm(readOnlyDir, { recursive: true, force: true });
});
});
describe('getDataDir', () => {
it('should return the data directory path', () => {
const dataDir = settingsService.getDataDir();
expect(dataDir).toBe(testDataDir);
});
});
describe('atomicWriteJson', () => {
it('should handle write errors and clean up temp file', async () => {
// Create a read-only directory to cause write errors
const readOnlyDir = path.join(os.tmpdir(), `readonly-${Date.now()}`);
await fs.mkdir(readOnlyDir, { recursive: true });
await fs.chmod(readOnlyDir, 0o444);
const readOnlyService = new SettingsService(readOnlyDir);
await expect(readOnlyService.updateGlobalSettings({ theme: 'light' })).rejects.toThrow();
await fs.chmod(readOnlyDir, 0o755);
await fs.rm(readOnlyDir, { recursive: true, force: true });
});
});
});