Files
automaker/apps/server/tests/unit/services/feature-loader.test.ts
Shirone a6d665c4fa fix: update logger tests to use console.log instead of console.warn
- Modified logger test cases to reflect that the warn method uses console.log in Node.js implementation.
- Updated expectations in feature-loader tests to align with the new logging behavior.
- Ensured consistent logging behavior across tests for improved clarity and accuracy.
2026-01-03 03:03:50 +01:00

446 lines
14 KiB
TypeScript

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');
describe('feature-loader.ts', () => {
let loader: FeatureLoader;
const testProjectPath = '/test/project';
beforeEach(() => {
vi.clearAllMocks();
loader = new FeatureLoader();
});
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');
});
});
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('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('generateFeatureId', () => {
it('should generate unique feature ID with timestamp', () => {
const id1 = loader.generateFeatureId();
const id2 = loader.generateFeatureId();
expect(id1).toMatch(/^feature-\d+-[a-z0-9]+$/);
expect(id2).toMatch(/^feature-\d+-[a-z0-9]+$/);
expect(id1).not.toBe(id2);
});
it("should start with 'feature-'", () => {
const id = loader.generateFeatureId();
expect(id).toMatch(/^feature-/);
});
});
describe('getAll', () => {
it("should return empty array when features directory doesn't exist", async () => {
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 () => {
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,
]);
vi.mocked(fs.readFile)
.mockResolvedValueOnce(
JSON.stringify({
id: 'feature-1',
category: 'ui',
description: 'Feature 1',
})
)
.mockResolvedValueOnce(
JSON.stringify({
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');
});
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,
]);
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
vi.mocked(fs.readFile)
.mockResolvedValueOnce(
JSON.stringify({
category: 'ui',
description: 'Missing ID',
})
)
.mockResolvedValueOnce(
JSON.stringify({
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(consoleSpy).toHaveBeenCalledWith(
expect.stringMatching(/WARN.*\[FeatureLoader\]/),
expect.stringContaining("missing required 'id' field")
);
consoleSpy.mockRestore();
});
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,
]);
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',
})
);
const result = await loader.getAll(testProjectPath);
expect(result).toHaveLength(1);
expect(result[0].id).toBe('feature-2');
});
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,
]);
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
vi.mocked(fs.readFile).mockResolvedValue('invalid json{');
const result = await loader.getAll(testProjectPath);
expect(result).toEqual([]);
expect(consoleSpy).toHaveBeenCalledWith(
expect.stringMatching(/WARN.*\[FeatureLoader\]/),
expect.stringContaining('Failed to parse feature.json')
);
consoleSpy.mockRestore();
});
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,
]);
vi.mocked(fs.readFile)
.mockResolvedValueOnce(
JSON.stringify({
id: 'feature-3000-xyz',
category: 'ui',
})
)
.mockResolvedValueOnce(
JSON.stringify({
id: 'feature-1000-abc',
category: 'ui',
})
)
.mockResolvedValueOnce(
JSON.stringify({
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');
});
});
describe('get', () => {
it('should return feature by ID', async () => {
const featureData = {
id: 'feature-123',
category: 'ui',
description: 'Test feature',
};
vi.mocked(fs.readFile).mockResolvedValue(JSON.stringify(featureData));
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';
vi.mocked(fs.readFile).mockRejectedValue(error);
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'));
await expect(loader.get(testProjectPath, 'feature-123')).rejects.toThrow('Permission denied');
});
});
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',
};
const result = await loader.create(testProjectPath, featureData);
expect(result).toMatchObject({
category: 'ui',
description: 'New feature',
id: expect.stringMatching(/^feature-/),
});
expect(fs.writeFile).toHaveBeenCalled();
});
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',
});
expect(result.id).toBe('custom-id');
});
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',
});
expect(result.category).toBe('Uncategorized');
});
});
describe('update', () => {
it('should update existing feature', async () => {
vi.mocked(fs.readFile).mockResolvedValue(
JSON.stringify({
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',
});
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';
vi.mocked(fs.readFile).mockRejectedValue(error);
await expect(loader.update(testProjectPath, 'feature-123', {})).rejects.toThrow('not found');
});
});
describe('delete', () => {
it('should delete feature directory', async () => {
vi.mocked(fs.rm).mockResolvedValue(undefined);
const result = await loader.delete(testProjectPath, 'feature-123');
expect(result).toBe(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'));
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const result = await loader.delete(testProjectPath, 'feature-123');
expect(result).toBe(false);
expect(consoleSpy).toHaveBeenCalledWith(
expect.stringMatching(/ERROR.*\[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');
const result = await loader.getAgentOutput(testProjectPath, 'feature-123');
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';
vi.mocked(fs.readFile).mockRejectedValue(error);
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'));
await expect(loader.getAgentOutput(testProjectPath, 'feature-123')).rejects.toThrow(
'Permission denied'
);
});
});
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');
expect(fs.writeFile).toHaveBeenCalledWith(
expect.stringContaining('agent-output.md'),
'Output content',
'utf-8'
);
});
});
describe('deleteAgentOutput', () => {
it('should delete agent output file', async () => {
vi.mocked(fs.unlink).mockResolvedValue(undefined);
await loader.deleteAgentOutput(testProjectPath, 'feature-123');
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';
vi.mocked(fs.unlink).mockRejectedValue(error);
// Should not throw
await expect(
loader.deleteAgentOutput(testProjectPath, 'feature-123')
).resolves.toBeUndefined();
});
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'
);
});
});
});