Spaces:
Sleeping
Sleeping
natnael kahssay
feat: use real moav2 source as RL task suite — symlinked sandbox, 3 real service tasks
ce25387 | /** | |
| * Browser History Persistence tests. | |
| * | |
| * Verifies that the DatabaseManager (IndexedDB-based) correctly persists | |
| * sessions and messages across re-initialization, which simulates a browser | |
| * page reload. Uses fake-indexeddb to provide IndexedDB in the jsdom test env. | |
| * | |
| * Tests cover: | |
| * - Database initialization | |
| * - Session CRUD (create, read, list, update, delete) | |
| * - Message persistence (add, retrieve by session, update, delete) | |
| * - Session list ordering (most recently updated first) | |
| * - Message ordering (chronological) | |
| * - Session deletion cascading to messages | |
| * - Empty database handling | |
| * - Data survives re-initialization (simulated page reload) | |
| */ | |
| import { describe, it, expect, beforeEach } from 'vitest' | |
| import 'fake-indexeddb/auto' | |
| import { IDBFactory } from 'fake-indexeddb' | |
| import { DatabaseManager } from '../core/services/db' | |
| // --------------------------------------------------------------------------- | |
| // Helper: small delay to ensure distinct timestamps | |
| // --------------------------------------------------------------------------- | |
| function delay(ms: number): Promise<void> { | |
| return new Promise(resolve => setTimeout(resolve, ms)) | |
| } | |
| // --------------------------------------------------------------------------- | |
| // Helper: create a fresh DatabaseManager instance (simulates page reload) | |
| // --------------------------------------------------------------------------- | |
| async function createFreshDb(): Promise<DatabaseManager> { | |
| const mgr = new DatabaseManager() | |
| await mgr.init() | |
| return mgr | |
| } | |
| // --------------------------------------------------------------------------- | |
| // Tests | |
| // --------------------------------------------------------------------------- | |
| describe('Browser History Persistence (DatabaseManager)', () => { | |
| let db: DatabaseManager | |
| beforeEach(async () => { | |
| // Reset IndexedDB completely for test isolation | |
| globalThis.indexedDB = new IDBFactory() | |
| db = await createFreshDb() | |
| }) | |
| // ========================================================================= | |
| // Database Initialization | |
| // ========================================================================= | |
| describe('Database Initialization', () => { | |
| it('initializes without errors', async () => { | |
| const mgr = new DatabaseManager() | |
| await expect(mgr.init()).resolves.not.toThrow() | |
| }) | |
| it('can be initialized multiple times on the same database (idempotent)', async () => { | |
| const mgr1 = await createFreshDb() | |
| const mgr2 = await createFreshDb() | |
| // Both should work independently on the same underlying DB | |
| const s1 = await mgr1.createSession('model-a') | |
| const s2 = await mgr2.createSession('model-b') | |
| expect(s1.id).toBeTruthy() | |
| expect(s2.id).toBeTruthy() | |
| }) | |
| }) | |
| // ========================================================================= | |
| // Session CRUD | |
| // ========================================================================= | |
| describe('Session CRUD', () => { | |
| it('creates a session with correct fields', async () => { | |
| const session = await db.createSession('claude-3.5-sonnet') | |
| expect(session.id).toBeTruthy() | |
| expect(session.title).toBe('New Chat') | |
| expect(session.model).toBe('claude-3.5-sonnet') | |
| expect(session.createdAt).toBeGreaterThan(0) | |
| expect(session.updatedAt).toBeGreaterThan(0) | |
| expect(session.createdAt).toBe(session.updatedAt) | |
| }) | |
| it('retrieves a session by ID', async () => { | |
| const created = await db.createSession('model-a') | |
| const retrieved = await db.getSession(created.id) | |
| expect(retrieved).not.toBeNull() | |
| expect(retrieved!.id).toBe(created.id) | |
| expect(retrieved!.title).toBe('New Chat') | |
| expect(retrieved!.model).toBe('model-a') | |
| }) | |
| it('returns null for non-existent session', async () => { | |
| const result = await db.getSession('non-existent-id') | |
| expect(result).toBeNull() | |
| }) | |
| it('lists all sessions', async () => { | |
| await db.createSession('model-a') | |
| await delay(10) | |
| await db.createSession('model-b') | |
| await delay(10) | |
| await db.createSession('model-c') | |
| const sessions = await db.listSessions() | |
| expect(sessions.length).toBe(3) | |
| }) | |
| it('lists sessions sorted by sortOrder ascending (creation date)', async () => { | |
| const s1 = await db.createSession('model-a') | |
| await delay(15) | |
| const s2 = await db.createSession('model-b') | |
| await delay(15) | |
| const s3 = await db.createSession('model-c') | |
| const sessions = await db.listSessions() | |
| expect(sessions.length).toBe(3) | |
| // Sorted by sortOrder ascending (oldest first by default) | |
| expect(sessions[0].id).toBe(s1.id) | |
| expect(sessions[1].id).toBe(s2.id) | |
| expect(sessions[2].id).toBe(s3.id) | |
| }) | |
| it('updates session title', async () => { | |
| const session = await db.createSession('model-a') | |
| await delay(10) | |
| const updated = await db.updateSession(session.id, { title: 'My Conversation' }) | |
| expect(updated).not.toBeNull() | |
| expect(updated!.title).toBe('My Conversation') | |
| expect(updated!.updatedAt).toBeGreaterThan(session.updatedAt) | |
| }) | |
| it('updates session model', async () => { | |
| const session = await db.createSession('model-a') | |
| await delay(10) | |
| const updated = await db.updateSession(session.id, { model: 'model-b' }) | |
| expect(updated).not.toBeNull() | |
| expect(updated!.model).toBe('model-b') | |
| }) | |
| it('updateSession returns null for non-existent session', async () => { | |
| const result = await db.updateSession('non-existent', { title: 'Nope' }) | |
| expect(result).toBeNull() | |
| }) | |
| it('removes a session', async () => { | |
| const session = await db.createSession('model-a') | |
| await db.removeSession(session.id) | |
| const result = await db.getSession(session.id) | |
| expect(result).toBeNull() | |
| }) | |
| it('session ordering is stable after update (sortOrder-based)', async () => { | |
| const s1 = await db.createSession('model-a') | |
| await delay(15) | |
| const s2 = await db.createSession('model-b') | |
| await delay(15) | |
| // s1 created first, so it should be first (sortOrder ascending) | |
| let sessions = await db.listSessions() | |
| expect(sessions[0].id).toBe(s1.id) | |
| // Updating s1 title does NOT change sortOrder — order remains stable | |
| await delay(15) | |
| await db.updateSession(s1.id, { title: 'Updated' }) | |
| sessions = await db.listSessions() | |
| expect(sessions[0].id).toBe(s1.id) | |
| expect(sessions[1].id).toBe(s2.id) | |
| }) | |
| }) | |
| // ========================================================================= | |
| // Message Persistence | |
| // ========================================================================= | |
| describe('Message Persistence', () => { | |
| let sessionId: string | |
| beforeEach(async () => { | |
| const session = await db.createSession('test-model') | |
| sessionId = session.id | |
| }) | |
| it('adds a message with correct fields', async () => { | |
| const msg = await db.addMessage(sessionId, 'user', 'Hello world') | |
| expect(msg.id).toBeTruthy() | |
| expect(msg.sessionId).toBe(sessionId) | |
| expect(msg.role).toBe('user') | |
| expect(msg.content).toBe('Hello world') | |
| expect(msg.createdAt).toBeGreaterThan(0) | |
| }) | |
| it('retrieves messages by session ID', async () => { | |
| await db.addMessage(sessionId, 'user', 'First message') | |
| await delay(5) | |
| await db.addMessage(sessionId, 'assistant', 'Second message') | |
| const messages = await db.getMessages(sessionId) | |
| expect(messages.length).toBe(2) | |
| expect(messages[0].content).toBe('First message') | |
| expect(messages[1].content).toBe('Second message') | |
| }) | |
| it('messages are ordered chronologically (createdAt ascending)', async () => { | |
| await db.addMessage(sessionId, 'user', 'First') | |
| await delay(10) | |
| await db.addMessage(sessionId, 'assistant', 'Second') | |
| await delay(10) | |
| await db.addMessage(sessionId, 'user', 'Third') | |
| const messages = await db.getMessages(sessionId) | |
| expect(messages.length).toBe(3) | |
| expect(messages[0].content).toBe('First') | |
| expect(messages[1].content).toBe('Second') | |
| expect(messages[2].content).toBe('Third') | |
| // Verify ordering | |
| expect(messages[0].createdAt).toBeLessThanOrEqual(messages[1].createdAt) | |
| expect(messages[1].createdAt).toBeLessThanOrEqual(messages[2].createdAt) | |
| }) | |
| it('messages are scoped to their session', async () => { | |
| const session2 = await db.createSession('model-b') | |
| await db.addMessage(sessionId, 'user', 'Session 1 message') | |
| await db.addMessage(session2.id, 'user', 'Session 2 message') | |
| const msgs1 = await db.getMessages(sessionId) | |
| const msgs2 = await db.getMessages(session2.id) | |
| expect(msgs1.length).toBe(1) | |
| expect(msgs1[0].content).toBe('Session 1 message') | |
| expect(msgs2.length).toBe(1) | |
| expect(msgs2[0].content).toBe('Session 2 message') | |
| }) | |
| it('addMessage updates session updatedAt', async () => { | |
| const sessionBefore = await db.getSession(sessionId) | |
| await delay(15) | |
| await db.addMessage(sessionId, 'user', 'New message') | |
| const sessionAfter = await db.getSession(sessionId) | |
| expect(sessionAfter).not.toBeNull() | |
| expect(sessionAfter!.updatedAt).toBeGreaterThan(sessionBefore!.updatedAt) | |
| }) | |
| it('adds message with blocks', async () => { | |
| const blocks = [ | |
| { id: 'b1', type: 'text' as const, content: 'Hello' }, | |
| { id: 'b2', type: 'tool' as const, toolName: 'bash', status: 'completed' as const }, | |
| ] | |
| const msg = await db.addMessage(sessionId, 'assistant', 'text', { blocks }) | |
| expect(msg.blocks).toBeDefined() | |
| expect(msg.blocks!.length).toBe(2) | |
| expect(msg.blocks![0].content).toBe('Hello') | |
| expect(msg.blocks![1].toolName).toBe('bash') | |
| }) | |
| it('adds message with partial flag', async () => { | |
| const msg = await db.addMessage(sessionId, 'assistant', '', { partial: true }) | |
| expect(msg.partial).toBe(true) | |
| }) | |
| it('adds message with custom ID', async () => { | |
| const customId = 'custom-msg-id-123' | |
| const msg = await db.addMessage(sessionId, 'user', 'With custom ID', { id: customId }) | |
| expect(msg.id).toBe(customId) | |
| }) | |
| it('updates message content', async () => { | |
| const msg = await db.addMessage(sessionId, 'assistant', 'Initial') | |
| await db.updateMessage(msg.id, { content: 'Updated content' }) | |
| const messages = await db.getMessages(sessionId) | |
| expect(messages[0].content).toBe('Updated content') | |
| }) | |
| it('updates message partial flag', async () => { | |
| const msg = await db.addMessage(sessionId, 'assistant', '', { partial: true }) | |
| await db.updateMessage(msg.id, { partial: false }) | |
| const messages = await db.getMessages(sessionId) | |
| // partial should be explicitly false | |
| expect(messages[0].partial).toBe(false) | |
| }) | |
| it('removes a message', async () => { | |
| const msg = await db.addMessage(sessionId, 'user', 'To be deleted') | |
| expect((await db.getMessages(sessionId)).length).toBe(1) | |
| await db.removeMessage(msg.id) | |
| expect((await db.getMessages(sessionId)).length).toBe(0) | |
| }) | |
| }) | |
| // ========================================================================= | |
| // Session Deletion Cascade | |
| // ========================================================================= | |
| describe('Session Deletion Cascade', () => { | |
| it('deleting a session also deletes its messages', async () => { | |
| const session = await db.createSession('model-a') | |
| await db.addMessage(session.id, 'user', 'Message 1') | |
| await db.addMessage(session.id, 'assistant', 'Message 2') | |
| await db.addMessage(session.id, 'user', 'Message 3') | |
| // Verify messages exist | |
| expect((await db.getMessages(session.id)).length).toBe(3) | |
| // Delete session | |
| await db.removeSession(session.id) | |
| // Session should be gone | |
| expect(await db.getSession(session.id)).toBeNull() | |
| // Messages should also be gone | |
| expect((await db.getMessages(session.id)).length).toBe(0) | |
| }) | |
| it('deleting a session does not affect other sessions messages', async () => { | |
| const s1 = await db.createSession('model-a') | |
| const s2 = await db.createSession('model-b') | |
| await db.addMessage(s1.id, 'user', 'S1 message') | |
| await db.addMessage(s2.id, 'user', 'S2 message') | |
| // Delete s1 | |
| await db.removeSession(s1.id) | |
| // s2 messages should be intact | |
| const s2Messages = await db.getMessages(s2.id) | |
| expect(s2Messages.length).toBe(1) | |
| expect(s2Messages[0].content).toBe('S2 message') | |
| }) | |
| }) | |
| // ========================================================================= | |
| // Empty Database Handling | |
| // ========================================================================= | |
| describe('Empty Database Handling', () => { | |
| it('listSessions returns empty array when no sessions exist', async () => { | |
| const sessions = await db.listSessions() | |
| expect(sessions).toEqual([]) | |
| }) | |
| it('getMessages returns empty array for non-existent session', async () => { | |
| const messages = await db.getMessages('non-existent-session-id') | |
| expect(messages).toEqual([]) | |
| }) | |
| it('getMessages returns empty array for session with no messages', async () => { | |
| const session = await db.createSession('model-a') | |
| const messages = await db.getMessages(session.id) | |
| expect(messages).toEqual([]) | |
| }) | |
| }) | |
| // ========================================================================= | |
| // Data Survives Re-initialization (Simulated Page Reload) | |
| // ========================================================================= | |
| describe('Persistence Across Re-initialization', () => { | |
| it('sessions survive re-initialization', async () => { | |
| const session = await db.createSession('model-a') | |
| await db.updateSession(session.id, { title: 'Persistent Chat' }) | |
| // Simulate page reload: create a new DatabaseManager on the same IndexedDB | |
| const db2 = await createFreshDb() | |
| const sessions = await db2.listSessions() | |
| expect(sessions.length).toBe(1) | |
| expect(sessions[0].id).toBe(session.id) | |
| expect(sessions[0].title).toBe('Persistent Chat') | |
| expect(sessions[0].model).toBe('model-a') | |
| }) | |
| it('messages survive re-initialization', async () => { | |
| const session = await db.createSession('model-a') | |
| await db.addMessage(session.id, 'user', 'Hello!') | |
| await delay(5) | |
| await db.addMessage(session.id, 'assistant', 'Hi there!') | |
| // Simulate page reload | |
| const db2 = await createFreshDb() | |
| const messages = await db2.getMessages(session.id) | |
| expect(messages.length).toBe(2) | |
| expect(messages[0].role).toBe('user') | |
| expect(messages[0].content).toBe('Hello!') | |
| expect(messages[1].role).toBe('assistant') | |
| expect(messages[1].content).toBe('Hi there!') | |
| }) | |
| it('multiple sessions and messages survive re-initialization', async () => { | |
| const s1 = await db.createSession('model-a') | |
| const s2 = await db.createSession('model-b') | |
| await db.addMessage(s1.id, 'user', 'S1 msg 1') | |
| await db.addMessage(s1.id, 'assistant', 'S1 msg 2') | |
| await db.addMessage(s2.id, 'user', 'S2 msg 1') | |
| // Simulate page reload | |
| const db2 = await createFreshDb() | |
| const sessions = await db2.listSessions() | |
| expect(sessions.length).toBe(2) | |
| const s1msgs = await db2.getMessages(s1.id) | |
| const s2msgs = await db2.getMessages(s2.id) | |
| expect(s1msgs.length).toBe(2) | |
| expect(s2msgs.length).toBe(1) | |
| }) | |
| it('message blocks survive re-initialization', async () => { | |
| const session = await db.createSession('model-a') | |
| const blocks = [ | |
| { id: 'blk-1', type: 'text' as const, content: 'Markdown content here' }, | |
| { id: 'blk-2', type: 'tool' as const, toolName: 'read', args: { path: '/a.txt' }, status: 'completed' as const, result: 'file contents' }, | |
| ] | |
| await db.addMessage(session.id, 'assistant', 'text', { blocks }) | |
| // Simulate page reload | |
| const db2 = await createFreshDb() | |
| const messages = await db2.getMessages(session.id) | |
| expect(messages.length).toBe(1) | |
| expect(messages[0].blocks).toBeDefined() | |
| expect(messages[0].blocks!.length).toBe(2) | |
| expect(messages[0].blocks![0].content).toBe('Markdown content here') | |
| expect(messages[0].blocks![1].toolName).toBe('read') | |
| expect(messages[0].blocks![1].result).toBe('file contents') | |
| }) | |
| it('session ordering is preserved after re-initialization', async () => { | |
| const s1 = await db.createSession('model-a') | |
| await delay(15) | |
| const s2 = await db.createSession('model-b') | |
| await delay(15) | |
| const s3 = await db.createSession('model-c') | |
| // Simulate page reload | |
| const db2 = await createFreshDb() | |
| const sessions = await db2.listSessions() | |
| // Sorted by sortOrder ascending (oldest first) | |
| expect(sessions[0].id).toBe(s1.id) | |
| expect(sessions[1].id).toBe(s2.id) | |
| expect(sessions[2].id).toBe(s3.id) | |
| }) | |
| it('deleted sessions stay deleted after re-initialization', async () => { | |
| const s1 = await db.createSession('model-a') | |
| const s2 = await db.createSession('model-b') | |
| await db.addMessage(s1.id, 'user', 'msg') | |
| await db.removeSession(s1.id) | |
| // Simulate page reload | |
| const db2 = await createFreshDb() | |
| const sessions = await db2.listSessions() | |
| expect(sessions.length).toBe(1) | |
| expect(sessions[0].id).toBe(s2.id) | |
| // s1 messages should also be gone | |
| const s1msgs = await db2.getMessages(s1.id) | |
| expect(s1msgs.length).toBe(0) | |
| }) | |
| }) | |
| // ========================================================================= | |
| // Provider Operations (Basic Verification) | |
| // ========================================================================= | |
| describe('Provider Operations', () => { | |
| it('adds and lists providers', async () => { | |
| const provider = await db.addProvider('TestProvider', 'https://api.test.com', 'test-key-123') | |
| expect(provider.id).toBeTruthy() | |
| expect(provider.name).toBe('TestProvider') | |
| expect(provider.baseUrl).toBe('https://api.test.com') | |
| const providers = await db.listProviders() | |
| expect(providers.length).toBe(1) | |
| expect(providers[0].name).toBe('TestProvider') | |
| }) | |
| it('removes trailing slash from baseUrl', async () => { | |
| const provider = await db.addProvider('Test', 'https://api.test.com///', 'key') | |
| expect(provider.baseUrl).toBe('https://api.test.com') | |
| }) | |
| it('providers survive re-initialization', async () => { | |
| await db.addProvider('Persistent', 'https://api.persist.com', 'key') | |
| const db2 = await createFreshDb() | |
| const providers = await db2.listProviders() | |
| expect(providers.length).toBe(1) | |
| expect(providers[0].name).toBe('Persistent') | |
| }) | |
| }) | |
| // ========================================================================= | |
| // OAuth Credentials (Basic Verification) | |
| // ========================================================================= | |
| describe('OAuth Credentials', () => { | |
| it('stores and retrieves OAuth credentials', async () => { | |
| await db.setOAuthCredentials('anthropic', { | |
| refresh: 'refresh-token', | |
| access: 'access-token', | |
| expires: Date.now() + 3600000, | |
| }) | |
| const creds = await db.getOAuthCredentials('anthropic') | |
| expect(creds).not.toBeNull() | |
| expect(creds!.refresh).toBe('refresh-token') | |
| expect(creds!.access).toBe('access-token') | |
| }) | |
| it('removes OAuth credentials', async () => { | |
| await db.setOAuthCredentials('anthropic', { | |
| refresh: 'r', access: 'a', expires: 0, | |
| }) | |
| await db.removeOAuthCredentials('anthropic') | |
| const creds = await db.getOAuthCredentials('anthropic') | |
| expect(creds).toBeNull() | |
| }) | |
| it('OAuth credentials survive re-initialization', async () => { | |
| await db.setOAuthCredentials('google', { | |
| refresh: 'g-refresh', access: 'g-access', expires: 9999999, | |
| }) | |
| const db2 = await createFreshDb() | |
| const creds = await db2.getOAuthCredentials('google') | |
| expect(creds).not.toBeNull() | |
| expect(creds!.refresh).toBe('g-refresh') | |
| }) | |
| }) | |
| // ========================================================================= | |
| // Edge Cases | |
| // ========================================================================= | |
| describe('Edge Cases', () => { | |
| it('handles many sessions', async () => { | |
| const count = 20 | |
| for (let i = 0; i < count; i++) { | |
| await db.createSession(`model-${i}`) | |
| } | |
| const sessions = await db.listSessions() | |
| expect(sessions.length).toBe(count) | |
| }) | |
| it('handles many messages in one session', async () => { | |
| const session = await db.createSession('model-a') | |
| const count = 50 | |
| for (let i = 0; i < count; i++) { | |
| await db.addMessage(session.id, i % 2 === 0 ? 'user' : 'assistant', `Message ${i}`) | |
| } | |
| const messages = await db.getMessages(session.id) | |
| expect(messages.length).toBe(count) | |
| // Verify chronological ordering | |
| for (let i = 1; i < messages.length; i++) { | |
| expect(messages[i].createdAt).toBeGreaterThanOrEqual(messages[i - 1].createdAt) | |
| } | |
| }) | |
| it('handles empty string content in messages', async () => { | |
| const session = await db.createSession('model-a') | |
| const msg = await db.addMessage(session.id, 'assistant', '') | |
| expect(msg.content).toBe('') | |
| const messages = await db.getMessages(session.id) | |
| expect(messages[0].content).toBe('') | |
| }) | |
| it('handles special characters in session title', async () => { | |
| const session = await db.createSession('model-a') | |
| await db.updateSession(session.id, { title: 'Session with "quotes" & <tags> and unicode: \u{1F680}' }) | |
| const retrieved = await db.getSession(session.id) | |
| expect(retrieved!.title).toBe('Session with "quotes" & <tags> and unicode: \u{1F680}') | |
| }) | |
| it('handles special characters in message content', async () => { | |
| const session = await db.createSession('model-a') | |
| const content = '```javascript\nconsole.log("hello")\n```\n\nUnicode: \u{1F600} \nHTML: <div class="test">&</div>' | |
| await db.addMessage(session.id, 'user', content) | |
| const messages = await db.getMessages(session.id) | |
| expect(messages[0].content).toBe(content) | |
| }) | |
| }) | |
| }) | |