Files
ai-terminal-assistant/tests/unit/session/storage.test.ts
T
kurihada 729fb2d42a 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
2025-12-11 14:45:24 +08:00

415 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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();
});
});
});