feat: 添加完整的单元测试套件
- 新增 vitest 测试框架配置 - 添加 54 个测试文件,共 951 个测试用例 - 覆盖核心模块: - Agent: executor, registry, config-loader, permission-merger - Context: manager, compaction, prune, token-counter - Permission: manager, bash/file/git/web checkers, wildcard - Session: manager, storage - Tools: filesystem (12个), git (10个), web, shell, todo, task - LSP: client, server, language - Utils: config, diff - UI: terminal
This commit is contained in:
@@ -0,0 +1,468 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { SessionManager } from '../../../src/session/manager.js';
|
||||
import { SessionStorage } from '../../../src/session/storage.js';
|
||||
import type { SessionData, Todo } from '../../../src/session/types.js';
|
||||
import type { ModelMessage } from 'ai';
|
||||
|
||||
// Mock SessionStorage
|
||||
class MockSessionStorage extends SessionStorage {
|
||||
private mockCurrentSession: SessionData | null = null;
|
||||
private mockSessions: Map<string, SessionData> = new Map();
|
||||
|
||||
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 saveCurrentSession(session: SessionData): Promise<void> {
|
||||
this.mockCurrentSession = { ...session, updatedAt: new Date().toISOString() };
|
||||
}
|
||||
|
||||
async loadCurrentSession(): Promise<SessionData | null> {
|
||||
return this.mockCurrentSession;
|
||||
}
|
||||
|
||||
async archiveCurrentSession(): Promise<void> {
|
||||
if (this.mockCurrentSession) {
|
||||
this.mockSessions.set(this.mockCurrentSession.id, { ...this.mockCurrentSession });
|
||||
this.mockCurrentSession = null;
|
||||
}
|
||||
}
|
||||
|
||||
async clearCurrentSession(): Promise<void> {
|
||||
this.mockCurrentSession = null;
|
||||
}
|
||||
|
||||
async listSessions(): Promise<{ id: string; title: string; workdir: string; messageCount: number; createdAt: string; updatedAt: string }[]> {
|
||||
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,
|
||||
}));
|
||||
}
|
||||
|
||||
async loadSession(sessionId: string): Promise<SessionData | null> {
|
||||
return this.mockSessions.get(sessionId) || null;
|
||||
}
|
||||
|
||||
async saveSession(session: SessionData): Promise<void> {
|
||||
this.mockSessions.set(session.id, { ...session, updatedAt: new Date().toISOString() });
|
||||
}
|
||||
|
||||
async deleteSession(sessionId: string): Promise<boolean> {
|
||||
return this.mockSessions.delete(sessionId);
|
||||
}
|
||||
|
||||
async cleanupOldSessions(keepCount: number = 50): Promise<number> {
|
||||
const sessions = await this.listSessions();
|
||||
if (sessions.length <= keepCount) return 0;
|
||||
const toDelete = sessions.slice(keepCount);
|
||||
let count = 0;
|
||||
for (const s of toDelete) {
|
||||
if (await this.deleteSession(s.id)) count++;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
// Helper methods for testing
|
||||
_setCurrentSession(session: SessionData | null): void {
|
||||
this.mockCurrentSession = session;
|
||||
}
|
||||
|
||||
_addSession(session: SessionData): void {
|
||||
this.mockSessions.set(session.id, session);
|
||||
}
|
||||
|
||||
_clear(): void {
|
||||
this.mockCurrentSession = null;
|
||||
this.mockSessions.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 existingSession: SessionData = {
|
||||
id: 'existing-session',
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
workdir: '/test/workdir',
|
||||
messages: [{ role: 'user', content: 'Hello' }],
|
||||
discoveredTools: ['tool1'],
|
||||
todos: [],
|
||||
};
|
||||
storage._setCurrentSession(existingSession);
|
||||
|
||||
const session = await manager.init('/test/workdir');
|
||||
|
||||
expect(session.id).toBe('existing-session');
|
||||
expect(session.messages).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('不同工作目录创建新会话并归档旧会话', async () => {
|
||||
const existingSession: SessionData = {
|
||||
id: 'old-session',
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
workdir: '/old/workdir',
|
||||
messages: [{ role: 'user', content: 'Old message' }],
|
||||
discoveredTools: [],
|
||||
todos: [],
|
||||
};
|
||||
storage._setCurrentSession(existingSession);
|
||||
|
||||
const session = await manager.init('/new/workdir');
|
||||
|
||||
expect(session.id).not.toBe('old-session');
|
||||
expect(session.workdir).toBe('/new/workdir');
|
||||
// 旧会话应该被归档
|
||||
const sessions = await storage.listSessions();
|
||||
expect(sessions.some((s) => s.id === 'old-session')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
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');
|
||||
});
|
||||
|
||||
it('空消息会话不归档', async () => {
|
||||
const emptySession = await manager.newSession('/empty/workdir');
|
||||
const anotherSession = await manager.newSession('/another/workdir');
|
||||
|
||||
// 空会话不应该被归档
|
||||
const sessions = await manager.listSessions();
|
||||
expect(sessions.every((s) => s.id !== emptySession.id)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
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.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.newSession('/another/workdir');
|
||||
});
|
||||
|
||||
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' });
|
||||
// 创建后续会话(使用 newSession 避免 init 的额外归档)
|
||||
for (let i = 1; i <= 2; i++) {
|
||||
await manager.newSession(`/workdir${i}`);
|
||||
await manager.addMessage({ role: 'user', content: `Message ${i}` });
|
||||
}
|
||||
// 最后归档当前会话
|
||||
await manager.newSession('/final');
|
||||
});
|
||||
|
||||
it('列出历史会话', async () => {
|
||||
const sessions = await manager.listSessions();
|
||||
expect(sessions.length).toBe(3);
|
||||
});
|
||||
|
||||
it('删除历史会话', async () => {
|
||||
const sessions = await manager.listSessions();
|
||||
const toDelete = sessions[0].id;
|
||||
|
||||
const result = await manager.deleteSession(toDelete);
|
||||
|
||||
expect(result).toBe(true);
|
||||
const remaining = await manager.listSessions();
|
||||
expect(remaining.length).toBe(2);
|
||||
});
|
||||
|
||||
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' });
|
||||
// 创建 9 个后续会话(使用 newSession 避免 init 的额外归档)
|
||||
for (let i = 1; i <= 9; i++) {
|
||||
await manager.newSession(`/workdir${i}`);
|
||||
await manager.addMessage({ role: 'user', content: `Message ${i}` });
|
||||
}
|
||||
// 最后归档当前会话
|
||||
await manager.newSession('/final');
|
||||
});
|
||||
|
||||
it('清理保留指定数量的会话', async () => {
|
||||
const deleted = await manager.cleanup(5);
|
||||
|
||||
expect(deleted).toBe(5);
|
||||
const remaining = await manager.listSessions();
|
||||
expect(remaining.length).toBe(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]+$/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,414 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
|
||||
// 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),
|
||||
stat: vi.fn().mockResolvedValue({ isDirectory: () => false }),
|
||||
}));
|
||||
|
||||
// Mock os
|
||||
vi.mock('os', () => ({
|
||||
homedir: vi.fn(() => '/home/testuser'),
|
||||
}));
|
||||
|
||||
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');
|
||||
});
|
||||
|
||||
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();
|
||||
|
||||
expect(fs.mkdir).toHaveBeenCalledWith(
|
||||
expect.stringContaining('sessions'),
|
||||
{ recursive: true }
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('saveCurrentSession - 保存当前会话', () => {
|
||||
it('保存会话数据', async () => {
|
||||
const session = {
|
||||
id: 'test-session',
|
||||
workdir: '/test',
|
||||
messages: [{ role: 'user', content: 'hello' }],
|
||||
createdAt: '2024-01-01T00:00:00Z',
|
||||
updatedAt: '2024-01-01T00:00:00Z',
|
||||
};
|
||||
|
||||
await storage.saveCurrentSession(session as any);
|
||||
|
||||
expect(fs.mkdir).toHaveBeenCalled();
|
||||
expect(fs.writeFile).toHaveBeenCalledWith(
|
||||
expect.stringContaining('current-session.json'),
|
||||
expect.any(String),
|
||||
'utf-8'
|
||||
);
|
||||
});
|
||||
|
||||
it('更新 updatedAt 时间戳', async () => {
|
||||
const session = {
|
||||
id: 'test-session',
|
||||
workdir: '/test',
|
||||
messages: [],
|
||||
createdAt: '2024-01-01T00:00:00Z',
|
||||
updatedAt: '2024-01-01T00:00:00Z',
|
||||
};
|
||||
|
||||
await storage.saveCurrentSession(session as any);
|
||||
|
||||
const writeCall = vi.mocked(fs.writeFile).mock.calls[0];
|
||||
const savedData = JSON.parse(writeCall[1] as string);
|
||||
expect(new Date(savedData.updatedAt).getTime()).toBeGreaterThan(
|
||||
new Date('2024-01-01T00:00:00Z').getTime()
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('loadCurrentSession - 加载当前会话', () => {
|
||||
it('成功加载会话', async () => {
|
||||
const sessionData = {
|
||||
id: 'test-session',
|
||||
workdir: '/test',
|
||||
messages: [],
|
||||
createdAt: '2024-01-01T00:00:00Z',
|
||||
updatedAt: '2024-01-01T00:00:00Z',
|
||||
};
|
||||
vi.mocked(fs.readFile).mockResolvedValueOnce(JSON.stringify(sessionData));
|
||||
|
||||
const session = await storage.loadCurrentSession();
|
||||
|
||||
expect(session).toEqual(sessionData);
|
||||
});
|
||||
|
||||
it('文件不存在返回 null', async () => {
|
||||
vi.mocked(fs.readFile).mockRejectedValueOnce(new Error('ENOENT'));
|
||||
|
||||
const session = await storage.loadCurrentSession();
|
||||
|
||||
expect(session).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('archiveCurrentSession - 归档当前会话', () => {
|
||||
it('归档有消息的会话', async () => {
|
||||
const sessionData = {
|
||||
id: 'test-session',
|
||||
workdir: '/test',
|
||||
messages: [{ role: 'user', content: 'test' }],
|
||||
createdAt: '2024-01-01T00:00:00Z',
|
||||
updatedAt: '2024-01-01T00:00:00Z',
|
||||
};
|
||||
vi.mocked(fs.readFile).mockResolvedValueOnce(JSON.stringify(sessionData));
|
||||
|
||||
await storage.archiveCurrentSession();
|
||||
|
||||
expect(fs.writeFile).toHaveBeenCalledWith(
|
||||
expect.stringContaining('test-session.json'),
|
||||
expect.any(String),
|
||||
'utf-8'
|
||||
);
|
||||
});
|
||||
|
||||
it('空会话不归档', async () => {
|
||||
const sessionData = {
|
||||
id: 'test-session',
|
||||
workdir: '/test',
|
||||
messages: [],
|
||||
createdAt: '2024-01-01T00:00:00Z',
|
||||
updatedAt: '2024-01-01T00:00:00Z',
|
||||
};
|
||||
vi.mocked(fs.readFile).mockResolvedValueOnce(JSON.stringify(sessionData));
|
||||
|
||||
await storage.archiveCurrentSession();
|
||||
|
||||
// writeFile 不应该被调用(只有 ensureDir 的 mkdir)
|
||||
expect(fs.writeFile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('无当前会话不操作', async () => {
|
||||
vi.mocked(fs.readFile).mockRejectedValueOnce(new Error('ENOENT'));
|
||||
|
||||
await storage.archiveCurrentSession();
|
||||
|
||||
expect(fs.writeFile).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('clearCurrentSession - 清除当前会话', () => {
|
||||
it('删除当前会话文件', async () => {
|
||||
await storage.clearCurrentSession();
|
||||
|
||||
expect(fs.unlink).toHaveBeenCalledWith(
|
||||
expect.stringContaining('current-session.json')
|
||||
);
|
||||
});
|
||||
|
||||
it('文件不存在不报错', async () => {
|
||||
vi.mocked(fs.unlink).mockRejectedValueOnce(new Error('ENOENT'));
|
||||
|
||||
await expect(storage.clearCurrentSession()).resolves.not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('listSessions - 列出历史会话', () => {
|
||||
it('返回会话摘要列表', async () => {
|
||||
vi.mocked(fs.readdir).mockResolvedValueOnce(['session1.json', 'session2.json'] as any);
|
||||
|
||||
const session1 = {
|
||||
id: 'session1',
|
||||
workdir: '/test1',
|
||||
messages: [{ role: 'user', content: '第一条消息' }],
|
||||
createdAt: '2024-01-01T00:00:00Z',
|
||||
updatedAt: '2024-01-02T00:00:00Z',
|
||||
};
|
||||
const session2 = {
|
||||
id: 'session2',
|
||||
workdir: '/test2',
|
||||
messages: [],
|
||||
createdAt: '2024-01-03T00:00:00Z',
|
||||
updatedAt: '2024-01-04T00:00:00Z',
|
||||
};
|
||||
|
||||
vi.mocked(fs.readFile)
|
||||
.mockResolvedValueOnce(JSON.stringify(session1))
|
||||
.mockResolvedValueOnce(JSON.stringify(session2));
|
||||
|
||||
const sessions = await storage.listSessions();
|
||||
|
||||
expect(sessions).toHaveLength(2);
|
||||
// 按更新时间降序
|
||||
expect(sessions[0].id).toBe('session2');
|
||||
expect(sessions[1].id).toBe('session1');
|
||||
});
|
||||
|
||||
it('生成会话标题', async () => {
|
||||
vi.mocked(fs.readdir).mockResolvedValueOnce(['session1.json'] as any);
|
||||
|
||||
const session = {
|
||||
id: 'session1',
|
||||
workdir: '/test',
|
||||
messages: [{ role: 'user', content: '这是第一条用户消息' }],
|
||||
createdAt: '2024-01-01T00:00:00Z',
|
||||
updatedAt: '2024-01-01T00:00:00Z',
|
||||
};
|
||||
vi.mocked(fs.readFile).mockResolvedValueOnce(JSON.stringify(session));
|
||||
|
||||
const sessions = await storage.listSessions();
|
||||
|
||||
expect(sessions[0].title).toBe('这是第一条用户消息');
|
||||
});
|
||||
|
||||
it('长标题截断', async () => {
|
||||
vi.mocked(fs.readdir).mockResolvedValueOnce(['session1.json'] as any);
|
||||
|
||||
const longContent = 'a'.repeat(100);
|
||||
const session = {
|
||||
id: 'session1',
|
||||
workdir: '/test',
|
||||
messages: [{ role: 'user', content: longContent }],
|
||||
createdAt: '2024-01-01T00:00:00Z',
|
||||
updatedAt: '2024-01-01T00:00:00Z',
|
||||
};
|
||||
vi.mocked(fs.readFile).mockResolvedValueOnce(JSON.stringify(session));
|
||||
|
||||
const sessions = await storage.listSessions();
|
||||
|
||||
expect(sessions[0].title.length).toBeLessThanOrEqual(53); // 50 + '...'
|
||||
});
|
||||
|
||||
it('跳过非 JSON 文件', async () => {
|
||||
vi.mocked(fs.readdir).mockResolvedValueOnce(['session.json', 'readme.txt'] as any);
|
||||
|
||||
const session = {
|
||||
id: 'session',
|
||||
workdir: '/test',
|
||||
messages: [],
|
||||
createdAt: '2024-01-01T00:00:00Z',
|
||||
updatedAt: '2024-01-01T00:00:00Z',
|
||||
};
|
||||
vi.mocked(fs.readFile).mockResolvedValueOnce(JSON.stringify(session));
|
||||
|
||||
const sessions = await storage.listSessions();
|
||||
|
||||
expect(sessions).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('跳过无法解析的文件', async () => {
|
||||
vi.mocked(fs.readdir).mockResolvedValueOnce(['session.json'] as any);
|
||||
vi.mocked(fs.readFile).mockResolvedValueOnce('invalid json');
|
||||
|
||||
const sessions = await storage.listSessions();
|
||||
|
||||
expect(sessions).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('loadSession - 加载指定会话', () => {
|
||||
it('成功加载会话', async () => {
|
||||
const sessionData = {
|
||||
id: 'session-123',
|
||||
workdir: '/test',
|
||||
messages: [],
|
||||
createdAt: '2024-01-01T00:00:00Z',
|
||||
updatedAt: '2024-01-01T00:00:00Z',
|
||||
};
|
||||
vi.mocked(fs.readFile).mockResolvedValueOnce(JSON.stringify(sessionData));
|
||||
|
||||
const session = await storage.loadSession('session-123');
|
||||
|
||||
expect(session).toEqual(sessionData);
|
||||
expect(fs.readFile).toHaveBeenCalledWith(
|
||||
expect.stringContaining('session-123.json'),
|
||||
'utf-8'
|
||||
);
|
||||
});
|
||||
|
||||
it('会话不存在返回 null', async () => {
|
||||
vi.mocked(fs.readFile).mockRejectedValueOnce(new Error('ENOENT'));
|
||||
|
||||
const session = await storage.loadSession('nonexistent');
|
||||
|
||||
expect(session).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('saveSession - 保存指定会话', () => {
|
||||
it('保存会话到文件', async () => {
|
||||
const session = {
|
||||
id: 'child-session',
|
||||
workdir: '/test',
|
||||
messages: [{ role: 'assistant', content: 'response' }],
|
||||
createdAt: '2024-01-01T00:00:00Z',
|
||||
updatedAt: '2024-01-01T00:00:00Z',
|
||||
};
|
||||
|
||||
await storage.saveSession(session as any);
|
||||
|
||||
expect(fs.writeFile).toHaveBeenCalledWith(
|
||||
expect.stringContaining('child-session.json'),
|
||||
expect.any(String),
|
||||
'utf-8'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteSession - 删除会话', () => {
|
||||
it('成功删除返回 true', async () => {
|
||||
const result = await storage.deleteSession('session-123');
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(fs.unlink).toHaveBeenCalledWith(
|
||||
expect.stringContaining('session-123.json')
|
||||
);
|
||||
});
|
||||
|
||||
it('删除失败返回 false', async () => {
|
||||
vi.mocked(fs.unlink).mockRejectedValueOnce(new Error('ENOENT'));
|
||||
|
||||
const result = await storage.deleteSession('nonexistent');
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('cleanupOldSessions - 清理旧会话', () => {
|
||||
it('删除超出保留数量的会话', async () => {
|
||||
// Mock listSessions 返回 3 个会话
|
||||
vi.mocked(fs.readdir).mockResolvedValueOnce([
|
||||
'session1.json',
|
||||
'session2.json',
|
||||
'session3.json',
|
||||
] as any);
|
||||
|
||||
const sessions = [
|
||||
{ id: 'session3', updatedAt: '2024-01-03' },
|
||||
{ id: 'session2', updatedAt: '2024-01-02' },
|
||||
{ id: 'session1', updatedAt: '2024-01-01' },
|
||||
];
|
||||
|
||||
vi.mocked(fs.readFile)
|
||||
.mockResolvedValueOnce(JSON.stringify({ ...sessions[0], messages: [], workdir: '/', createdAt: '2024-01-01' }))
|
||||
.mockResolvedValueOnce(JSON.stringify({ ...sessions[1], messages: [], workdir: '/', createdAt: '2024-01-01' }))
|
||||
.mockResolvedValueOnce(JSON.stringify({ ...sessions[2], messages: [], workdir: '/', createdAt: '2024-01-01' }));
|
||||
|
||||
const deletedCount = await storage.cleanupOldSessions(2);
|
||||
|
||||
expect(deletedCount).toBe(1);
|
||||
expect(fs.unlink).toHaveBeenCalledWith(
|
||||
expect.stringContaining('session1.json')
|
||||
);
|
||||
});
|
||||
|
||||
it('会话数量不超过保留数量不删除', async () => {
|
||||
vi.mocked(fs.readdir).mockResolvedValueOnce(['session1.json'] as any);
|
||||
vi.mocked(fs.readFile).mockResolvedValueOnce(JSON.stringify({
|
||||
id: 'session1',
|
||||
messages: [],
|
||||
workdir: '/',
|
||||
createdAt: '2024-01-01',
|
||||
updatedAt: '2024-01-01',
|
||||
}));
|
||||
|
||||
const deletedCount = await storage.cleanupOldSessions(5);
|
||||
|
||||
expect(deletedCount).toBe(0);
|
||||
expect(fs.unlink).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user