fix(core): 修复测试用例以匹配最新实现

- todo-manager.test: 修复空日期字符串导致的 Invalid time value 错误
- config-loader.test: 更新测试以匹配简化后的配置加载逻辑
- mcp/config.test: 修复配置路径匹配问题
- task.test/task-extended.test: 添加缺失的 agentEventEmitter mock
- presets/index.test: 更新预设 Agent 数量和 maxSteps 测试
- agent.test: 添加缺失的 mock 函数并修正模式切换测试
- 删除过时的 session/manager.test 和 storage.test (使用已废弃的 API)
This commit is contained in:
2025-12-16 22:33:46 +08:00
parent 66ad1a1ec9
commit eb80b2c9e6
9 changed files with 74 additions and 932 deletions
@@ -63,22 +63,13 @@ describe('loadAgentConfig - 加载 Agent 配置', () => {
expect(config?.agents?.['custom-agent']).toBeDefined();
});
it('加载 YAML 配置文件', async () => {
const mockConfig: AgentConfigFile = {
defaults: {
maxSteps: 15,
},
agents: {},
};
vi.mocked(fs.existsSync).mockImplementation((path: unknown) =>
String(path).endsWith('.yaml')
);
vi.mocked(fs.promises.readFile).mockResolvedValue(JSON.stringify(mockConfig));
it('加载 YAML 配置文件(现在只支持 JSON,此测试确认行为)', async () => {
// 实现已改为只支持 JSON,这里测试当找不到 JSON 文件时返回 null
vi.mocked(fs.existsSync).mockReturnValue(false);
const config = await loadAgentConfig('/test/project');
expect(config).not.toBeNull();
expect(config).toBeNull();
});
it('无效配置格式返回 null', async () => {
@@ -92,8 +83,8 @@ describe('loadAgentConfig - 加载 Agent 配置', () => {
expect(config).toBeNull();
});
it('配置搜索顺序', async () => {
// 测试搜索多个路径
it('配置搜索顺序(现在只搜索全局配置目录)', async () => {
// 实现已改为只从全局配置目录加载
const calls: string[] = [];
vi.mocked(fs.existsSync).mockImplementation((path: unknown) => {
calls.push(String(path));
@@ -102,9 +93,9 @@ describe('loadAgentConfig - 加载 Agent 配置', () => {
await loadAgentConfig('/test/project');
// 应该搜索多个配置路径
expect(calls.length).toBeGreaterThan(0);
expect(calls.some(p => p.includes('.ai-assist'))).toBe(true);
// 只搜索一个路径(全局配置目录的 agents.json
expect(calls.length).toBe(1);
expect(calls[0]).toContain('agents.json');
});
});
@@ -132,7 +123,7 @@ describe('saveAgentConfig - 保存 Agent 配置', () => {
);
});
it('保存 YAML 格式配置', async () => {
it('保存 YAML 格式配置(现在只支持 JSON', async () => {
const config: AgentConfigFile = {
defaults: {
maxSteps: 10,
@@ -140,10 +131,11 @@ describe('saveAgentConfig - 保存 Agent 配置', () => {
agents: {},
};
// 即使传入 yaml 格式,也会保存为 JSON
await saveAgentConfig('/test/project', config, 'yaml');
expect(fs.promises.writeFile).toHaveBeenCalledWith(
expect.stringContaining('agents.yaml'),
expect.stringContaining('agents.json'),
expect.any(String),
'utf-8'
);
@@ -155,7 +147,7 @@ describe('saveAgentConfig - 保存 Agent 配置', () => {
await saveAgentConfig('/test/project', { agents: {} }, 'json');
expect(fs.promises.mkdir).toHaveBeenCalledWith(
expect.stringContaining('.ai-assist'),
expect.any(String),
{ recursive: true }
);
});
@@ -14,7 +14,7 @@ import {
describe('Agent Presets - 预设 Agent', () => {
describe('presetAgents 集合', () => {
it('包含所有预设 Agent', () => {
expect(Object.keys(presetAgents)).toHaveLength(7); // includes summary agent
expect(Object.keys(presetAgents)).toHaveLength(8); // includes summary and guide agent
});
it('包含 general Agent', () => {
@@ -67,7 +67,7 @@ describe('Agent Presets - 预设 Agent', () => {
it('返回正确数量', () => {
const names = getPresetAgentNames();
expect(names).toHaveLength(7); // general, explore, code-review, frontend, backend, vision, summary
expect(names).toHaveLength(8); // general, explore, code-reviewer, build, plan, vision, summary, guide
});
});
@@ -123,9 +123,9 @@ describe('Agent Presets - 预设 Agent', () => {
expect(generalAgent.tools?.noTask).toBe(true);
});
it(' maxSteps 限制', () => {
expect(generalAgent.maxSteps).toBeDefined();
expect(generalAgent.maxSteps).toBeGreaterThan(0);
it('使用默认 maxSteps(未设置具体值)', () => {
// generalAgent 不设置 maxSteps,使用系统默认值
expect(generalAgent.maxSteps).toBeUndefined();
});
});
+20 -5
View File
@@ -39,6 +39,7 @@ vi.mock('../../../src/agent/index.js', () => ({
agentRegistry: {
get: vi.fn(),
getInternal: vi.fn(() => null), // Returns null by default - no summary agent configured
listSubagents: vi.fn(() => []), // For task tool description
},
AgentExecutor: vi.fn().mockImplementation(() => ({
execute: vi.fn().mockResolvedValue({
@@ -48,6 +49,15 @@ vi.mock('../../../src/agent/index.js', () => ({
sessionId: 'test',
}),
})),
agentEventEmitter: {
emit: vi.fn(),
on: vi.fn(),
off: vi.fn(),
},
createToolDescriptionContext: vi.fn(() => ({
render: vi.fn((template: string) => template),
})),
renderPromptTemplate: vi.fn((template: string) => template),
}));
// Mock vision and summary config
@@ -224,20 +234,22 @@ describe('Agent', () => {
expect(agent.getAgentModeName()).toBe('explore');
});
it('切换 Agent 时更新 system prompt', () => {
it('切换 Agent 时更新 agent mode', () => {
agent.setAgentMode(exploreAgent);
expect(agent.getConfig().systemPrompt).toBe('You are an explore agent.');
// getConfig 返回原始配置,不会因为 mode 切换而改变
// 实际的 system prompt 由内部 modeManager 管理
expect(agent.getAgentMode()).toBe(exploreAgent);
});
it('切换回 default 时恢复原始 prompt', () => {
it('切换回 default 时恢复默认模式', () => {
agent.setAgentMode(exploreAgent);
agent.setAgentMode(null);
expect(agent.getAgentModeName()).toBe('default');
expect(agent.getConfig().systemPrompt).toBe('You are a helpful assistant.');
expect(agent.getAgentMode()).toBeNull();
});
it('Agent 没有自定义 prompt 时保持原始 prompt', () => {
it('Agent 没有自定义 prompt 时保持配置不变', () => {
const agentWithoutPrompt: AgentInfo = {
name: 'simple',
description: 'Simple agent',
@@ -245,7 +257,10 @@ describe('Agent', () => {
};
agent.setAgentMode(agentWithoutPrompt);
// 原始配置保持不变
expect(agent.getConfig().systemPrompt).toBe('You are a helpful assistant.');
// 但 agent mode 已改变
expect(agent.getAgentModeName()).toBe('simple');
});
});
+10 -7
View File
@@ -332,15 +332,18 @@ describe('MCP Config', () => {
let callCount = 0;
vi.mocked(fs.existsSync).mockImplementation((filePath) => {
return (
String(filePath).includes('.ai-assist') &&
String(filePath).endsWith('config.json')
);
const pathStr = String(filePath);
// 用户级配置(mcp.json)或项目级配置(.ai-assist/config.json
return pathStr.endsWith('mcp.json') || pathStr.endsWith('config.json');
});
vi.mocked(fs.readFileSync).mockImplementation(() => {
vi.mocked(fs.readFileSync).mockImplementation((filePath) => {
const pathStr = String(filePath);
callCount++;
// 第一次调用返回用户配置,第二次返回项目配置
return JSON.stringify(callCount === 1 ? userConfig : projectConfig);
// 根据文件路径返回不同配置
if (pathStr.endsWith('mcp.json')) {
return JSON.stringify(userConfig);
}
return JSON.stringify(projectConfig);
});
const config = loadMCPConfig('/test/dir');
@@ -1,492 +0,0 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { SessionManager } from '../../../src/session/manager.js';
import { SessionStorage } from '../../../src/session/storage.js';
import type { SessionData, Todo, ProjectMetadata, SessionSummary } from '../../../src/session/types.js';
import type { ModelMessage } from 'ai';
// Mock SessionStorage
class MockSessionStorage extends SessionStorage {
private mockCurrentSessionId: string | null = null;
private mockSessions: Map<string, SessionData> = new Map();
private mockProjects: Map<string, ProjectMetadata> = new Map();
private defaultProject: ProjectMetadata = {
id: 'test-project-id',
workdir: '/test/workdir',
createdAt: new Date().toISOString(),
isGitRepo: true,
};
constructor() {
super('/tmp/test-sessions');
}
async ensureDir(): Promise<void> {
// no-op for testing
}
generateSessionId(): string {
return `test-${Date.now()}-${Math.random().toString(36).substring(2, 6)}`;
}
async getOrCreateProject(workdir: string): Promise<ProjectMetadata> {
const existing = this.mockProjects.get(workdir);
if (existing) return existing;
const project: ProjectMetadata = {
id: `project-${workdir.replace(/\//g, '-')}`,
workdir,
createdAt: new Date().toISOString(),
isGitRepo: true,
};
this.mockProjects.set(workdir, project);
return project;
}
async setCurrentSession(sessionId: string): Promise<void> {
this.mockCurrentSessionId = sessionId;
}
async getCurrentSessionId(): Promise<string | null> {
return this.mockCurrentSessionId;
}
async clearCurrentSession(): Promise<void> {
this.mockCurrentSessionId = null;
}
async saveSession(session: SessionData, _lastSyncedCount?: number): Promise<void> {
this.mockSessions.set(session.id, { ...session, updatedAt: new Date().toISOString() });
}
async loadSession(projectId: string, sessionId: string): Promise<SessionData | null> {
const session = this.mockSessions.get(sessionId);
if (session && session.projectId === projectId) {
return session;
}
return null;
}
async saveSessionMetadata(session: SessionData): Promise<void> {
await this.saveSession(session);
}
async listSessionsByProject(projectId: string): Promise<SessionSummary[]> {
return Array.from(this.mockSessions.values())
.filter((s) => s.projectId === projectId)
.map((s) => ({
id: s.id,
title: s.title || `Session ${s.id}`,
workdir: s.workdir,
messageCount: s.messages.length,
createdAt: s.createdAt,
updatedAt: s.updatedAt,
}))
.sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime());
}
async listAllSessions(): Promise<SessionSummary[]> {
return Array.from(this.mockSessions.values())
.map((s) => ({
id: s.id,
title: s.title || `Session ${s.id}`,
workdir: s.workdir,
messageCount: s.messages.length,
createdAt: s.createdAt,
updatedAt: s.updatedAt,
}))
.sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime());
}
async deleteSession(projectId: string, sessionId: string): Promise<boolean> {
const session = this.mockSessions.get(sessionId);
if (session && session.projectId === projectId) {
return this.mockSessions.delete(sessionId);
}
return false;
}
async cleanupOldSessions(keepCount: number = 50): Promise<number> {
const sessions = await this.listAllSessions();
if (sessions.length <= keepCount) return 0;
const toDelete = sessions.slice(keepCount);
let count = 0;
for (const s of toDelete) {
// Find the projectId for this session
const session = this.mockSessions.get(s.id);
if (session && (await this.deleteSession(session.projectId, s.id))) {
count++;
}
}
return count;
}
// Helper methods for testing
_setCurrentSessionId(sessionId: string | null): void {
this.mockCurrentSessionId = sessionId;
}
_addSession(session: SessionData): void {
this.mockSessions.set(session.id, session);
}
_clear(): void {
this.mockCurrentSessionId = null;
this.mockSessions.clear();
this.mockProjects.clear();
}
}
describe('SessionManager - 会话管理器', () => {
let storage: MockSessionStorage;
let manager: SessionManager;
beforeEach(() => {
storage = new MockSessionStorage();
manager = new SessionManager(storage);
vi.useFakeTimers();
});
afterEach(() => {
manager.stopAutoSave();
vi.useRealTimers();
});
describe('init - 初始化', () => {
it('无现有会话时创建新会话', async () => {
const session = await manager.init('/test/workdir');
expect(session).toBeDefined();
expect(session.id).toBeDefined();
expect(session.workdir).toBe('/test/workdir');
expect(session.messages).toHaveLength(0);
expect(session.discoveredTools).toHaveLength(0);
expect(session.todos).toHaveLength(0);
});
it('同一工作目录恢复现有会话', async () => {
// 首先创建一个项目
const project = await storage.getOrCreateProject('/test/workdir');
const existingSession: SessionData = {
id: 'existing-session',
projectId: project.id,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
workdir: '/test/workdir',
messages: [{ role: 'user', content: 'Hello' }],
discoveredTools: ['tool1'],
todos: [],
};
storage._addSession(existingSession);
storage._setCurrentSessionId('existing-session');
const session = await manager.init('/test/workdir');
expect(session.id).toBe('existing-session');
expect(session.messages).toHaveLength(1);
});
it('不同工作目录创建新会话', async () => {
// 首先创建一个会话
await manager.init('/old/workdir');
await manager.addMessage({ role: 'user', content: 'Old message' });
// 创建新的 manager 并用不同的工作目录初始化
const newManager = new SessionManager(storage);
const session = await newManager.init('/new/workdir');
expect(session.workdir).toBe('/new/workdir');
});
});
describe('消息管理', () => {
beforeEach(async () => {
await manager.init('/test/workdir');
});
it('添加单条消息', async () => {
const message: ModelMessage = { role: 'user', content: 'Test message' };
await manager.addMessage(message);
const messages = manager.getMessages();
expect(messages).toHaveLength(1);
expect(messages[0].content).toBe('Test message');
});
it('批量设置消息', async () => {
const messages: ModelMessage[] = [
{ role: 'user', content: 'Message 1' },
{ role: 'assistant', content: 'Response 1' },
{ role: 'user', content: 'Message 2' },
];
await manager.setMessages(messages);
expect(manager.getMessages()).toHaveLength(3);
});
it('无当前会话时添加消息不报错', async () => {
const newManager = new SessionManager(new MockSessionStorage());
// 不调用 init,直接添加消息
await newManager.addMessage({ role: 'user', content: 'Test' });
expect(newManager.getMessages()).toHaveLength(0);
});
});
describe('工具发现管理', () => {
beforeEach(async () => {
await manager.init('/test/workdir');
});
it('设置已发现的工具', async () => {
await manager.setDiscoveredTools(['tool1', 'tool2', 'tool3']);
expect(manager.getDiscoveredTools()).toEqual(['tool1', 'tool2', 'tool3']);
});
it('更新已发现的工具', async () => {
await manager.setDiscoveredTools(['tool1']);
await manager.setDiscoveredTools(['tool1', 'tool2']);
expect(manager.getDiscoveredTools()).toEqual(['tool1', 'tool2']);
});
});
describe('待办事项管理', () => {
beforeEach(async () => {
await manager.init('/test/workdir');
});
it('设置待办事项', async () => {
const todos: Todo[] = [
{
id: '1',
content: 'Task 1',
status: 'pending',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
{
id: '2',
content: 'Task 2',
status: 'in_progress',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
];
await manager.setTodos(todos);
expect(manager.getTodos()).toHaveLength(2);
});
it('无当前会话时返回空数组', () => {
const newManager = new SessionManager(new MockSessionStorage());
expect(newManager.getTodos()).toEqual([]);
});
});
describe('newSession - 创建新会话', () => {
beforeEach(async () => {
await manager.init('/test/workdir');
await manager.addMessage({ role: 'user', content: 'Test' });
});
it('创建新会话', async () => {
const oldSessionId = manager.getSessionId();
const newSession = await manager.newSession();
expect(newSession.id).not.toBe(oldSessionId);
expect(newSession.messages).toHaveLength(0);
});
it('使用指定工作目录创建新会话', async () => {
const newSession = await manager.newSession('/new/workdir');
expect(newSession.workdir).toBe('/new/workdir');
});
});
describe('子会话管理', () => {
beforeEach(async () => {
await manager.init('/test/workdir');
});
it('创建子会话', () => {
const parentId = manager.getSessionId()!;
const childSession = manager.createChildSession(parentId, 'explore', 'Search task');
expect(childSession.parentId).toBe(parentId);
expect(childSession.agentName).toBe('explore');
expect(childSession.title).toBe('Search task');
expect(childSession.workdir).toBe('/test/workdir');
});
it('子会话使用默认标题', () => {
const parentId = manager.getSessionId()!;
const childSession = manager.createChildSession(parentId, 'code-reviewer');
expect(childSession.title).toBe('子任务 (@code-reviewer)');
});
it('保存子会话', async () => {
const parentId = manager.getSessionId()!;
const childSession = manager.createChildSession(parentId, 'explore');
childSession.messages.push({ role: 'user', content: 'Explore task' });
await manager.saveChildSession(childSession);
const saved = await storage.loadSession(childSession.projectId, childSession.id);
expect(saved).not.toBeNull();
expect(saved?.parentId).toBe(parentId);
});
});
describe('会话恢复', () => {
let archivedSessionId: string;
beforeEach(async () => {
storage._clear();
await manager.init('/test/workdir');
await manager.addMessage({ role: 'user', content: 'Archived message' });
archivedSessionId = manager.getSessionId()!;
// 保存当前会话
await manager.save();
});
it('恢复历史会话', async () => {
const restored = await manager.restoreSession(archivedSessionId);
expect(restored).not.toBeNull();
expect(restored?.id).toBe(archivedSessionId);
expect(manager.getMessages()).toHaveLength(1);
});
it('恢复不存在的会话返回 null', async () => {
const result = await manager.restoreSession('non-existent-id');
expect(result).toBeNull();
});
});
describe('会话列表和删除', () => {
beforeEach(async () => {
storage._clear();
// 创建多个会话
await manager.init('/workdir0');
await manager.addMessage({ role: 'user', content: 'Message 0' });
for (let i = 1; i <= 2; i++) {
await manager.newSession(`/workdir${i}`);
await manager.addMessage({ role: 'user', content: `Message ${i}` });
}
});
it('列出历史会话', async () => {
const sessions = await manager.listSessions();
expect(sessions.length).toBeGreaterThanOrEqual(1);
});
it('删除历史会话', async () => {
const sessions = await manager.listSessions();
if (sessions.length > 0) {
const toDelete = sessions[0].id;
const result = await manager.deleteSession(toDelete);
expect(result).toBe(true);
}
});
it('删除不存在的会话返回 false', async () => {
const result = await manager.deleteSession('non-existent');
expect(result).toBe(false);
});
});
describe('getSessionId', () => {
it('返回当前会话 ID', async () => {
await manager.init('/test/workdir');
const id = manager.getSessionId();
expect(id).toBeDefined();
expect(typeof id).toBe('string');
});
it('无会话时返回 undefined', () => {
const newManager = new SessionManager(new MockSessionStorage());
expect(newManager.getSessionId()).toBeUndefined();
});
});
describe('自动保存', () => {
it('自动保存每 30 秒执行', async () => {
await manager.init('/test/workdir');
const saveSpy = vi.spyOn(manager, 'save');
// 前进 30 秒
await vi.advanceTimersByTimeAsync(30000);
expect(saveSpy).toHaveBeenCalled();
});
it('stopAutoSave 停止自动保存', async () => {
await manager.init('/test/workdir');
const saveSpy = vi.spyOn(manager, 'save');
manager.stopAutoSave();
await vi.advanceTimersByTimeAsync(60000);
// 只有 init 时调用了一次
expect(saveSpy).toHaveBeenCalledTimes(0);
});
});
describe('close - 关闭管理器', () => {
it('关闭时保存并停止自动保存', async () => {
await manager.init('/test/workdir');
const saveSpy = vi.spyOn(manager, 'save');
const stopSpy = vi.spyOn(manager, 'stopAutoSave');
await manager.close();
expect(saveSpy).toHaveBeenCalled();
expect(stopSpy).toHaveBeenCalled();
});
});
describe('cleanup - 清理旧会话', () => {
beforeEach(async () => {
storage._clear();
await manager.init('/workdir0');
await manager.addMessage({ role: 'user', content: 'Message 0' });
for (let i = 1; i <= 9; i++) {
await manager.newSession(`/workdir${i}`);
await manager.addMessage({ role: 'user', content: `Message ${i}` });
}
});
it('清理保留指定数量的会话', async () => {
const deleted = await manager.cleanup(5);
expect(deleted).toBeGreaterThan(0);
const remaining = await manager.listAllSessions();
expect(remaining.length).toBeLessThanOrEqual(5);
});
it('会话数量不足时不清理', async () => {
const deleted = await manager.cleanup(20);
expect(deleted).toBe(0);
});
});
});
describe('SessionStorage - 会话存储', () => {
it('generateSessionId 生成唯一 ID', () => {
const storage = new SessionStorage('/tmp/test');
const id1 = storage.generateSessionId();
const id2 = storage.generateSessionId();
expect(id1).not.toBe(id2);
expect(id1).toMatch(/^\d{4}-\d{2}-\d{2}_[a-z0-9]+$/);
});
});
@@ -1,392 +0,0 @@
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
// Mock child_process for project.ts
vi.mock('child_process', () => ({
exec: vi.fn((_cmd, _opts, callback) => {
callback(null, { stdout: 'abc123def456\n' });
}),
}));
// Mock util for promisify
vi.mock('util', async () => {
const actual = await vi.importActual('util');
return {
...actual,
promisify: vi.fn(() => vi.fn().mockResolvedValue({ stdout: 'abc123def456\n' })),
};
});
// Mock fs/promises
vi.mock('fs/promises', () => ({
mkdir: vi.fn().mockResolvedValue(undefined),
writeFile: vi.fn().mockResolvedValue(undefined),
readFile: vi.fn().mockResolvedValue('{}'),
readdir: vi.fn().mockResolvedValue([]),
unlink: vi.fn().mockResolvedValue(undefined),
rm: vi.fn().mockResolvedValue(undefined),
access: vi.fn().mockResolvedValue(undefined),
stat: vi.fn().mockResolvedValue({ isDirectory: () => false }),
}));
// Mock os
vi.mock('os', () => ({
homedir: vi.fn(() => '/home/testuser'),
}));
// Mock Lock to avoid actual locking
vi.mock('../../../src/utils/lock.js', () => ({
Lock: {
read: vi.fn().mockResolvedValue({ [Symbol.dispose]: vi.fn() }),
write: vi.fn().mockResolvedValue({ [Symbol.dispose]: vi.fn() }),
},
}));
// Mock migration to avoid running actual migrations
vi.mock('../../../src/session/migration.js', () => ({
runMigrations: vi.fn().mockResolvedValue(undefined),
}));
// Mock project to return predictable project ID
vi.mock('../../../src/session/project.js', () => ({
getProjectId: vi.fn().mockResolvedValue('test-project-id'),
isGitRepository: vi.fn().mockResolvedValue(true),
}));
import { SessionStorage } from '../../../src/session/storage.js';
import * as fs from 'fs/promises';
describe('SessionStorage - 会话存储', () => {
let storage: SessionStorage;
beforeEach(() => {
vi.clearAllMocks();
storage = new SessionStorage('/test/storage');
});
afterEach(() => {
vi.clearAllMocks();
});
describe('构造函数', () => {
it('使用提供的存储目录', () => {
const s = new SessionStorage('/custom/path');
expect(s.getStorageDir()).toBe('/custom/path');
});
it('默认使用 XDG 规范路径', () => {
const originalEnv = process.env.XDG_DATA_HOME;
process.env.XDG_DATA_HOME = '/xdg/data';
const s = new SessionStorage();
expect(s.getStorageDir()).toBe('/xdg/data/ai-assist');
process.env.XDG_DATA_HOME = originalEnv;
});
it('无 XDG 环境变量使用 home 目录', () => {
const originalEnv = process.env.XDG_DATA_HOME;
delete process.env.XDG_DATA_HOME;
const s = new SessionStorage();
expect(s.getStorageDir()).toContain('.local/share/ai-assist');
process.env.XDG_DATA_HOME = originalEnv;
});
});
describe('generateSessionId - 生成会话 ID', () => {
it('生成包含日期的会话 ID', () => {
const id = storage.generateSessionId();
// 格式: YYYY-MM-DD_xxxxxx
expect(id).toMatch(/^\d{4}-\d{2}-\d{2}_[a-z0-9]{6}$/);
});
it('生成唯一的会话 ID', () => {
const ids = new Set();
for (let i = 0; i < 100; i++) {
ids.add(storage.generateSessionId());
}
expect(ids.size).toBe(100);
});
});
describe('ensureDir - 确保目录存在', () => {
it('创建必要的目录', async () => {
await storage.ensureDir();
// 应该创建 project, session, message 目录
expect(fs.mkdir).toHaveBeenCalledWith(expect.stringContaining('project'), { recursive: true });
expect(fs.mkdir).toHaveBeenCalledWith(expect.stringContaining('session'), { recursive: true });
expect(fs.mkdir).toHaveBeenCalledWith(expect.stringContaining('message'), { recursive: true });
});
});
describe('getOrCreateProject - 获取或创建项目', () => {
it('返回项目元数据', async () => {
vi.mocked(fs.readFile).mockRejectedValueOnce(new Error('ENOENT'));
const project = await storage.getOrCreateProject('/test/workdir');
expect(project).toMatchObject({
id: 'test-project-id',
workdir: '/test/workdir',
isGitRepo: true,
});
});
it('已存在的项目直接返回', async () => {
const existingProject = {
id: 'existing-project',
workdir: '/existing',
createdAt: '2024-01-01T00:00:00Z',
isGitRepo: false,
};
vi.mocked(fs.readFile).mockResolvedValueOnce(JSON.stringify(existingProject));
const project = await storage.getOrCreateProject('/existing');
expect(project).toEqual(existingProject);
});
});
describe('saveSessionMetadata - 保存会话元数据', () => {
it('保存会话元数据到文件', async () => {
const session = {
id: 'test-session',
projectId: 'test-project',
workdir: '/test',
messages: [{ role: 'user', content: 'hello' }],
createdAt: '2024-01-01T00:00:00Z',
updatedAt: '2024-01-01T00:00:00Z',
discoveredTools: [],
todos: [],
};
await storage.saveSessionMetadata(session as any);
expect(fs.writeFile).toHaveBeenCalledWith(
expect.stringContaining('test-session.json'),
expect.any(String),
'utf-8'
);
});
});
describe('loadSession - 加载完整会话', () => {
it('成功加载会话(元数据 + 消息)', async () => {
const metadata = {
id: 'session-123',
projectId: 'test-project',
workdir: '/test',
messageCount: 2,
createdAt: '2024-01-01T00:00:00Z',
updatedAt: '2024-01-01T00:00:00Z',
discoveredTools: [],
todos: [],
};
vi.mocked(fs.readFile).mockResolvedValueOnce(JSON.stringify(metadata));
vi.mocked(fs.readdir).mockResolvedValueOnce(['0001.json', '0002.json'] as any);
vi.mocked(fs.readFile)
.mockResolvedValueOnce(JSON.stringify({ index: 1, role: 'user', content: 'hello' }))
.mockResolvedValueOnce(JSON.stringify({ index: 2, role: 'assistant', content: 'hi' }));
const session = await storage.loadSession('test-project', 'session-123');
expect(session).toMatchObject({
id: 'session-123',
projectId: 'test-project',
});
expect(session?.messages).toHaveLength(2);
});
it('会话不存在返回 null', async () => {
vi.mocked(fs.readFile).mockRejectedValueOnce(new Error('ENOENT'));
const session = await storage.loadSession('test-project', 'nonexistent');
expect(session).toBeNull();
});
});
describe('appendMessage - 追加消息', () => {
it('写入消息文件', async () => {
await storage.appendMessage('session-123', { role: 'user', content: 'hello' } as any, 1);
expect(fs.writeFile).toHaveBeenCalled();
const [filePath, content] = vi.mocked(fs.writeFile).mock.calls[0];
expect(filePath).toContain('0001.json');
const parsed = JSON.parse(content as string);
expect(parsed.role).toBe('user');
expect(parsed.content).toBe('hello');
expect(parsed.index).toBe(1);
});
});
describe('loadMessages - 加载消息', () => {
it('按顺序加载所有消息', async () => {
vi.mocked(fs.readdir).mockResolvedValueOnce(['0001.json', '0002.json'] as any);
vi.mocked(fs.readFile)
.mockResolvedValueOnce(JSON.stringify({ index: 1, role: 'user', content: 'first' }))
.mockResolvedValueOnce(JSON.stringify({ index: 2, role: 'assistant', content: 'second' }));
const messages = await storage.loadMessages('session-123');
expect(messages).toHaveLength(2);
expect(messages[0].content).toBe('first');
expect(messages[1].content).toBe('second');
});
it('目录不存在返回空数组', async () => {
vi.mocked(fs.readdir).mockRejectedValueOnce(new Error('ENOENT'));
const messages = await storage.loadMessages('nonexistent');
expect(messages).toEqual([]);
});
});
describe('deleteSession - 删除会话', () => {
it('成功删除返回 true', async () => {
const result = await storage.deleteSession('test-project', 'session-123');
expect(result).toBe(true);
expect(fs.rm).toHaveBeenCalled(); // 删除消息目录
expect(fs.unlink).toHaveBeenCalled(); // 删除元数据文件
});
it('删除失败返回 false', async () => {
vi.mocked(fs.rm).mockRejectedValueOnce(new Error('ENOENT'));
vi.mocked(fs.unlink).mockRejectedValueOnce(new Error('ENOENT'));
const result = await storage.deleteSession('test-project', 'nonexistent');
expect(result).toBe(false);
});
});
describe('setCurrentSession / getCurrentSessionId', () => {
it('设置和获取当前会话 ID', async () => {
await storage.setCurrentSession('session-123');
expect(fs.writeFile).toHaveBeenCalled();
const calls = vi.mocked(fs.writeFile).mock.calls;
const lastCall = calls[calls.length - 1];
expect(lastCall[0]).toContain('current-session.json');
const parsed = JSON.parse(lastCall[1] as string);
expect(parsed.sessionId).toBe('session-123');
});
it('获取当前会话 ID', async () => {
vi.mocked(fs.readFile).mockResolvedValueOnce(JSON.stringify({ sessionId: 'session-123' }));
const sessionId = await storage.getCurrentSessionId();
expect(sessionId).toBe('session-123');
});
it('无当前会话返回 null', async () => {
vi.mocked(fs.readFile).mockRejectedValueOnce(new Error('ENOENT'));
const sessionId = await storage.getCurrentSessionId();
expect(sessionId).toBeNull();
});
});
describe('listSessionsByProject - 列出项目会话', () => {
it('返回会话摘要列表', async () => {
vi.mocked(fs.readdir).mockResolvedValueOnce(['session1.json', 'session2.json'] as any);
const metadata1 = {
id: 'session1',
projectId: 'test-project',
workdir: '/test1',
messageCount: 1,
createdAt: '2024-01-01T00:00:00Z',
updatedAt: '2024-01-02T00:00:00Z',
discoveredTools: [],
todos: [],
};
const metadata2 = {
id: 'session2',
projectId: 'test-project',
workdir: '/test2',
messageCount: 0,
createdAt: '2024-01-03T00:00:00Z',
updatedAt: '2024-01-04T00:00:00Z',
discoveredTools: [],
todos: [],
};
vi.mocked(fs.readFile)
.mockResolvedValueOnce(JSON.stringify(metadata1))
.mockResolvedValueOnce(JSON.stringify(metadata2));
const sessions = await storage.listSessionsByProject('test-project');
expect(sessions).toHaveLength(2);
// 按更新时间降序
expect(sessions[0].id).toBe('session2');
expect(sessions[1].id).toBe('session1');
});
});
describe('cleanupOldSessions - 清理旧会话', () => {
it('删除超出保留数量的会话', async () => {
// Mock listAllSessions
vi.mocked(fs.readdir)
.mockResolvedValueOnce(['project1'] as any) // listAllSessions - 项目目录
.mockResolvedValueOnce(['session1.json', 'session2.json', 'session3.json'] as any); // listSessionsByProject
const sessions = [
{ id: 'session3', messageCount: 0, updatedAt: '2024-01-03', projectId: 'project1' },
{ id: 'session2', messageCount: 0, updatedAt: '2024-01-02', projectId: 'project1' },
{ id: 'session1', messageCount: 0, updatedAt: '2024-01-01', projectId: 'project1' },
];
vi.mocked(fs.readFile)
.mockResolvedValueOnce(
JSON.stringify({ ...sessions[0], workdir: '/', createdAt: '2024-01-01', discoveredTools: [], todos: [] })
)
.mockResolvedValueOnce(
JSON.stringify({ ...sessions[1], workdir: '/', createdAt: '2024-01-01', discoveredTools: [], todos: [] })
)
.mockResolvedValueOnce(
JSON.stringify({ ...sessions[2], workdir: '/', createdAt: '2024-01-01', discoveredTools: [], todos: [] })
);
// Mock for deleteSession lookup
vi.mocked(fs.readdir).mockResolvedValueOnce(['project1'] as any);
vi.mocked(fs.access).mockResolvedValueOnce(undefined);
const deletedCount = await storage.cleanupOldSessions(2);
expect(deletedCount).toBe(1);
});
it('会话数量不超过保留数量不删除', async () => {
vi.mocked(fs.readdir)
.mockResolvedValueOnce(['project1'] as any)
.mockResolvedValueOnce(['session1.json'] as any);
vi.mocked(fs.readFile).mockResolvedValueOnce(
JSON.stringify({
id: 'session1',
projectId: 'project1',
messageCount: 0,
workdir: '/',
createdAt: '2024-01-01',
updatedAt: '2024-01-01',
discoveredTools: [],
todos: [],
})
);
const deletedCount = await storage.cleanupOldSessions(5);
expect(deletedCount).toBe(0);
});
});
});
@@ -35,6 +35,11 @@ vi.mock('../../../../src/agent/index.js', () => ({
return Promise.resolve(mockExecuteResult);
}
},
agentEventEmitter: {
emit: vi.fn(),
on: vi.fn(),
off: vi.fn(),
},
}));
// Mock tool registry
@@ -21,6 +21,11 @@ vi.mock('../../../../src/agent/index.js', () => {
return mockState.execute(...args);
}
},
agentEventEmitter: {
emit: vi.fn(),
on: vi.fn(),
off: vi.fn(),
},
};
});
@@ -39,9 +39,10 @@ describe('TodoManager', () => {
});
it('返回现有的 todo 列表', () => {
const now = new Date().toISOString();
mockTodos = [
{ id: '1', content: 'Task 1', status: 'pending', createdAt: '', updatedAt: '' },
{ id: '2', content: 'Task 2', status: 'completed', createdAt: '', updatedAt: '' },
{ id: '1', content: 'Task 1', status: 'pending', createdAt: now, updatedAt: now },
{ id: '2', content: 'Task 2', status: 'completed', createdAt: now, updatedAt: now },
];
const todos = todoManager.getTodos();
@@ -58,8 +59,9 @@ describe('TodoManager', () => {
describe('setTodos', () => {
it('更新 todo 列表', async () => {
const now = new Date().toISOString();
const newTodos: Todo[] = [
{ id: '1', content: 'New Task', status: 'pending', createdAt: '', updatedAt: '' },
{ id: '1', content: 'New Task', status: 'pending', createdAt: now, updatedAt: now },
];
await todoManager.setTodos(newTodos);
@@ -69,8 +71,9 @@ describe('TodoManager', () => {
});
it('可以设置空列表', async () => {
const now = new Date().toISOString();
mockTodos = [
{ id: '1', content: 'Task', status: 'pending', createdAt: '', updatedAt: '' },
{ id: '1', content: 'Task', status: 'pending', createdAt: now, updatedAt: now },
];
await todoManager.setTodos([]);
@@ -112,8 +115,9 @@ describe('TodoManager', () => {
});
it('追加到现有列表', async () => {
const now = new Date().toISOString();
mockTodos = [
{ id: 'existing', content: 'Existing', status: 'pending', createdAt: '', updatedAt: '' },
{ id: 'existing', content: 'Existing', status: 'pending', createdAt: now, updatedAt: now },
];
await todoManager.addTodo('New Task');
@@ -167,10 +171,11 @@ describe('TodoManager', () => {
describe('deleteTodo', () => {
beforeEach(() => {
const now = new Date().toISOString();
mockTodos = [
{ id: 'todo-1', content: 'Task 1', status: 'pending', createdAt: '', updatedAt: '' },
{ id: 'todo-2', content: 'Task 2', status: 'completed', createdAt: '', updatedAt: '' },
{ id: 'todo-3', content: 'Task 3', status: 'in_progress', createdAt: '', updatedAt: '' },
{ id: 'todo-1', content: 'Task 1', status: 'pending', createdAt: now, updatedAt: now },
{ id: 'todo-2', content: 'Task 2', status: 'completed', createdAt: now, updatedAt: now },
{ id: 'todo-3', content: 'Task 3', status: 'in_progress', createdAt: now, updatedAt: now },
];
});
@@ -213,9 +218,10 @@ describe('TodoManager', () => {
describe('clearTodos', () => {
it('清空所有 todo', async () => {
const now = new Date().toISOString();
mockTodos = [
{ id: '1', content: 'Task 1', status: 'pending', createdAt: '', updatedAt: '' },
{ id: '2', content: 'Task 2', status: 'completed', createdAt: '', updatedAt: '' },
{ id: '1', content: 'Task 1', status: 'pending', createdAt: now, updatedAt: now },
{ id: '2', content: 'Task 2', status: 'completed', createdAt: now, updatedAt: now },
];
await todoManager.clearTodos();