729fb2d42a
- 新增 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
415 lines
12 KiB
TypeScript
415 lines
12 KiB
TypeScript
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();
|
||
});
|
||
});
|
||
});
|