diff --git a/src/__tests__/main/ipc/handlers/stats.test.ts b/src/__tests__/main/ipc/handlers/stats.test.ts
index ac0372db..b9f35103 100644
--- a/src/__tests__/main/ipc/handlers/stats.test.ts
+++ b/src/__tests__/main/ipc/handlers/stats.test.ts
@@ -71,6 +71,7 @@ describe('stats IPC handlers', () => {
sessionsByDay: [],
avgSessionDuration: 0,
byAgentByDay: {},
+ bySessionByDay: {},
}),
exportToCsv: vi.fn().mockReturnValue('id,sessionId,...'),
clearOldData: vi.fn().mockReturnValue({ success: true, deletedCount: 0 }),
diff --git a/src/__tests__/main/stats-db.test.ts b/src/__tests__/main/stats-db.test.ts
index c35ae474..114d262f 100644
--- a/src/__tests__/main/stats-db.test.ts
+++ b/src/__tests__/main/stats-db.test.ts
@@ -6396,2352 +6396,5 @@ describe('Database VACUUM functionality', () => {
});
});
- // ============================================================================
- // Database Integrity & Corruption Handling Tests
- // ============================================================================
-
- describe('Database Integrity & Corruption Handling', () => {
- beforeEach(() => {
- vi.resetModules();
- mockDb.pragma.mockReset();
- mockDb.prepare.mockReset();
- mockDb.close.mockReset();
- mockDb.transaction.mockReset();
- mockStatement.run.mockReset();
- mockStatement.get.mockReset();
- mockStatement.all.mockReset();
- mockFsExistsSync.mockReset();
- mockFsMkdirSync.mockReset();
- mockFsCopyFileSync.mockReset();
- mockFsUnlinkSync.mockReset();
- mockFsRenameSync.mockReset();
- mockFsStatSync.mockReset();
-
- // Default mocks
- mockFsExistsSync.mockReturnValue(true);
- mockFsStatSync.mockReturnValue({ size: 1024 });
- mockDb.prepare.mockReturnValue(mockStatement);
- mockStatement.run.mockReturnValue({ changes: 1 });
- mockStatement.get.mockReturnValue({ count: 0, total_duration: 0 });
- mockStatement.all.mockReturnValue([]);
- mockDb.transaction.mockImplementation((fn: () => void) => () => fn());
- });
-
- describe('checkIntegrity', () => {
- it('should return ok: true when integrity check passes', async () => {
- mockDb.pragma.mockImplementation((pragma: string) => {
- if (pragma === 'integrity_check') {
- return [{ integrity_check: 'ok' }];
- }
- return [{ user_version: 1 }];
- });
-
- const { StatsDB } = await import('../../main/stats-db');
- const db = new StatsDB();
- db.initialize();
- mockStatement.run.mockClear();
-
- const result = db.checkIntegrity();
-
- expect(result.ok).toBe(true);
- expect(result.errors).toEqual([]);
- });
-
- it('should return ok: false with errors when integrity check fails', async () => {
- mockDb.pragma.mockImplementation((pragma: string) => {
- if (pragma === 'integrity_check') {
- return [
- { integrity_check: 'wrong # of entries in index idx_query_start_time' },
- { integrity_check: 'row 123 missing from index idx_query_source' },
- ];
- }
- return [{ user_version: 1 }];
- });
-
- const { StatsDB } = await import('../../main/stats-db');
- const db = new StatsDB();
- db.initialize();
- mockStatement.run.mockClear();
-
- const result = db.checkIntegrity();
-
- expect(result.ok).toBe(false);
- expect(result.errors).toHaveLength(2);
- expect(result.errors[0]).toContain('idx_query_start_time');
- expect(result.errors[1]).toContain('row 123');
- });
-
- it('should return error when database is not initialized', async () => {
- const { StatsDB } = await import('../../main/stats-db');
- const db = new StatsDB();
- // Don't initialize
-
- const result = db.checkIntegrity();
-
- expect(result.ok).toBe(false);
- expect(result.errors).toContain('Database not initialized');
- });
-
- it('should handle pragma errors gracefully', async () => {
- mockDb.pragma.mockImplementation((pragma: string) => {
- if (pragma === 'integrity_check') {
- throw new Error('Database is locked');
- }
- return [{ user_version: 1 }];
- });
-
- const { StatsDB } = await import('../../main/stats-db');
- const db = new StatsDB();
- db.initialize();
- mockStatement.run.mockClear();
-
- const result = db.checkIntegrity();
-
- expect(result.ok).toBe(false);
- expect(result.errors).toContain('Database is locked');
- });
- });
-
- describe('backupDatabase', () => {
- it('should create backup successfully when database file exists', async () => {
- mockFsExistsSync.mockReturnValue(true);
- mockFsCopyFileSync.mockImplementation(() => {});
- mockDb.pragma.mockReturnValue([{ user_version: 1 }]);
-
- const { StatsDB } = await import('../../main/stats-db');
- const db = new StatsDB();
- db.initialize();
- mockStatement.run.mockClear();
-
- const result = db.backupDatabase();
-
- expect(result.success).toBe(true);
- expect(result.backupPath).toMatch(/stats\.db\.backup\.\d+$/);
- expect(mockFsCopyFileSync).toHaveBeenCalled();
- });
-
- it('should fail when database file does not exist', async () => {
- // First call for directory exists (true), subsequent calls for file checks
- let existsCallCount = 0;
- mockFsExistsSync.mockImplementation((filePath: string) => {
- existsCallCount++;
- // First 2-3 calls are for directory and db during initialization
- if (existsCallCount <= 3) return true;
- // When backupDatabase checks for db file, return false
- if (typeof filePath === 'string' && filePath.endsWith('stats.db')) {
- return false;
- }
- return true;
- });
- mockDb.pragma.mockReturnValue([{ user_version: 1 }]);
-
- const { StatsDB } = await import('../../main/stats-db');
- const db = new StatsDB();
- db.initialize();
- mockStatement.run.mockClear();
-
- // Reset and set for backup call
- mockFsExistsSync.mockReturnValue(false);
-
- const result = db.backupDatabase();
-
- expect(result.success).toBe(false);
- expect(result.error).toBe('Database file does not exist');
- expect(result.backupPath).toBeUndefined();
- });
-
- it('should handle copy errors gracefully', async () => {
- mockFsExistsSync.mockReturnValue(true);
- mockFsCopyFileSync.mockImplementation(() => {
- throw new Error('Permission denied');
- });
- mockDb.pragma.mockReturnValue([{ user_version: 1 }]);
-
- const { StatsDB } = await import('../../main/stats-db');
- const db = new StatsDB();
- db.initialize();
- mockStatement.run.mockClear();
-
- const result = db.backupDatabase();
-
- expect(result.success).toBe(false);
- expect(result.error).toBe('Permission denied');
- });
-
- it('should generate unique backup filenames with timestamps', async () => {
- mockFsExistsSync.mockReturnValue(true);
- mockFsCopyFileSync.mockImplementation(() => {});
- mockDb.pragma.mockReturnValue([{ user_version: 1 }]);
-
- const { StatsDB } = await import('../../main/stats-db');
- const db = new StatsDB();
- db.initialize();
- mockStatement.run.mockClear();
-
- const beforeTimestamp = Date.now();
- const result = db.backupDatabase();
- const afterTimestamp = Date.now();
-
- expect(result.success).toBe(true);
- expect(result.backupPath).toBeDefined();
-
- // Extract timestamp from backup path
- const match = result.backupPath!.match(/\.backup\.(\d+)$/);
- expect(match).not.toBeNull();
- const backupTimestamp = parseInt(match![1], 10);
- expect(backupTimestamp).toBeGreaterThanOrEqual(beforeTimestamp);
- expect(backupTimestamp).toBeLessThanOrEqual(afterTimestamp);
- });
- });
-
- describe('corruption recovery during initialization', () => {
- it('should proceed normally when database is not corrupted', async () => {
- mockFsExistsSync.mockReturnValue(true);
- mockDb.pragma.mockImplementation((pragma: string) => {
- if (pragma === 'integrity_check') {
- return [{ integrity_check: 'ok' }];
- }
- if (pragma.startsWith('user_version')) {
- return [{ user_version: 1 }];
- }
- return [];
- });
-
- const { StatsDB } = await import('../../main/stats-db');
- const db = new StatsDB();
- db.initialize();
-
- expect(db.isReady()).toBe(true);
- expect(mockFsUnlinkSync).not.toHaveBeenCalled();
- expect(mockFsCopyFileSync).not.toHaveBeenCalled();
- });
-
- it('should backup and recreate database when corruption is detected', async () => {
- let dbOpenAttempts = 0;
- mockFsExistsSync.mockReturnValue(true);
- mockFsCopyFileSync.mockImplementation(() => {});
-
- mockDb.pragma.mockImplementation((pragma: string) => {
- if (pragma === 'integrity_check') {
- dbOpenAttempts++;
- // First open: corrupted
- if (dbOpenAttempts === 1) {
- return [{ integrity_check: 'database disk image is malformed' }];
- }
- // After recreation: ok
- return [{ integrity_check: 'ok' }];
- }
- if (pragma.startsWith('user_version')) {
- return [{ user_version: 0 }];
- }
- return [];
- });
-
- const { StatsDB } = await import('../../main/stats-db');
- const db = new StatsDB();
- db.initialize();
-
- // Database should still be usable after recovery
- expect(db.isReady()).toBe(true);
-
- // Backup should have been created
- expect(mockFsCopyFileSync).toHaveBeenCalled();
-
- // Old database files should have been cleaned up
- expect(mockFsUnlinkSync).toHaveBeenCalled();
- });
-
- it('should clean up WAL and SHM files during recovery', async () => {
- let walExists = true;
- let shmExists = true;
-
- mockFsExistsSync.mockImplementation((filePath: string) => {
- if (typeof filePath === 'string') {
- if (filePath.endsWith('-wal')) return walExists;
- if (filePath.endsWith('-shm')) return shmExists;
- }
- return true;
- });
-
- mockFsUnlinkSync.mockImplementation((filePath: string) => {
- if (typeof filePath === 'string') {
- if (filePath.endsWith('-wal')) walExists = false;
- if (filePath.endsWith('-shm')) shmExists = false;
- }
- });
-
- mockFsCopyFileSync.mockImplementation(() => {});
-
- let firstCall = true;
- mockDb.pragma.mockImplementation((pragma: string) => {
- if (pragma === 'integrity_check') {
- if (firstCall) {
- firstCall = false;
- return [{ integrity_check: 'malformed database' }];
- }
- return [{ integrity_check: 'ok' }];
- }
- return [{ user_version: 0 }];
- });
-
- const { StatsDB } = await import('../../main/stats-db');
- const db = new StatsDB();
- db.initialize();
-
- // WAL and SHM files should have been deleted
- const unlinkCalls = mockFsUnlinkSync.mock.calls.map((call) => call[0]);
- const walDeleted = unlinkCalls.some((path) => String(path).endsWith('-wal'));
- const shmDeleted = unlinkCalls.some((path) => String(path).endsWith('-shm'));
- expect(walDeleted).toBe(true);
- expect(shmDeleted).toBe(true);
- });
-
- it('should use emergency rename when copy backup fails', async () => {
- mockFsExistsSync.mockReturnValue(true);
-
- // Copy fails
- mockFsCopyFileSync.mockImplementation(() => {
- throw new Error('Disk full');
- });
-
- // Rename should be attempted as fallback
- mockFsRenameSync.mockImplementation(() => {});
-
- let firstCall = true;
- mockDb.pragma.mockImplementation((pragma: string) => {
- if (pragma === 'integrity_check') {
- if (firstCall) {
- firstCall = false;
- return [{ integrity_check: 'corrupted' }];
- }
- return [{ integrity_check: 'ok' }];
- }
- return [{ user_version: 0 }];
- });
-
- const { StatsDB } = await import('../../main/stats-db');
- const db = new StatsDB();
- db.initialize();
-
- // Emergency rename should have been attempted
- expect(mockFsRenameSync).toHaveBeenCalled();
- const renameCall = mockFsRenameSync.mock.calls[0];
- expect(String(renameCall[1])).toContain('.corrupted.');
- });
-
- it('should delete corrupted database as last resort when backup and rename both fail', async () => {
- mockFsExistsSync.mockReturnValue(true);
-
- // Both copy and rename fail
- mockFsCopyFileSync.mockImplementation(() => {
- throw new Error('Disk full');
- });
- mockFsRenameSync.mockImplementation(() => {
- throw new Error('Cross-device link not permitted');
- });
-
- let firstCall = true;
- mockDb.pragma.mockImplementation((pragma: string) => {
- if (pragma === 'integrity_check') {
- if (firstCall) {
- firstCall = false;
- return [{ integrity_check: 'corrupted' }];
- }
- return [{ integrity_check: 'ok' }];
- }
- return [{ user_version: 0 }];
- });
-
- const { StatsDB } = await import('../../main/stats-db');
- const db = new StatsDB();
- db.initialize();
-
- // Database should have been deleted
- expect(mockFsUnlinkSync).toHaveBeenCalled();
- expect(db.isReady()).toBe(true);
- });
-
- it('should throw error when recovery completely fails', async () => {
- mockFsExistsSync.mockReturnValue(true);
- mockFsCopyFileSync.mockImplementation(() => {
- throw new Error('Disk full');
- });
- mockFsRenameSync.mockImplementation(() => {
- throw new Error('Permission denied');
- });
- mockFsUnlinkSync.mockImplementation(() => {
- throw new Error('File in use');
- });
-
- mockDb.pragma.mockImplementation((pragma: string) => {
- if (pragma === 'integrity_check') {
- return [{ integrity_check: 'corrupted' }];
- }
- return [{ user_version: 0 }];
- });
-
- const { StatsDB } = await import('../../main/stats-db');
- const db = new StatsDB();
-
- expect(() => db.initialize()).toThrow();
- });
-
- it('should not run corruption check for new databases', async () => {
- // Database file does not exist initially
- let firstCheck = true;
- mockFsExistsSync.mockImplementation((filePath: string) => {
- if (typeof filePath === 'string' && filePath.endsWith('stats.db')) {
- if (firstCheck) {
- firstCheck = false;
- return false; // Database doesn't exist
- }
- }
- return true; // Directory exists
- });
-
- mockDb.pragma.mockReturnValue([{ user_version: 0 }]);
-
- const { StatsDB } = await import('../../main/stats-db');
- const db = new StatsDB();
- db.initialize();
-
- // For new database, integrity_check should not be called during open
- // (only during explicit checkIntegrity() calls)
- const integrityCheckCalls = mockDb.pragma.mock.calls.filter(
- (call) => call[0] === 'integrity_check'
- );
- expect(integrityCheckCalls.length).toBe(0);
- });
-
- it('should handle database open failure and recover', async () => {
- let constructorCallCount = 0;
-
- // Mock Database constructor to fail first time, succeed second time
- vi.doMock('better-sqlite3', () => {
- return {
- default: class MockDatabase {
- constructor(dbPath: string) {
- constructorCallCount++;
- lastDbPath = dbPath;
- if (constructorCallCount === 1) {
- throw new Error('unable to open database file');
- }
- }
- pragma = mockDb.pragma;
- prepare = mockDb.prepare;
- close = mockDb.close;
- transaction = mockDb.transaction;
- },
- };
- });
-
- mockFsExistsSync.mockReturnValue(true);
- mockFsCopyFileSync.mockImplementation(() => {});
- mockDb.pragma.mockReturnValue([{ user_version: 0 }]);
-
- // Need to re-import to get the new mock
- const { StatsDB } = await import('../../main/stats-db');
- const db = new StatsDB();
- db.initialize();
-
- expect(db.isReady()).toBe(true);
- expect(constructorCallCount).toBe(2); // First failed, second succeeded
- });
- });
-
- describe('IntegrityCheckResult type', () => {
- it('should have correct structure for success', async () => {
- mockDb.pragma.mockImplementation((pragma: string) => {
- if (pragma === 'integrity_check') {
- return [{ integrity_check: 'ok' }];
- }
- return [{ user_version: 1 }];
- });
-
- const { StatsDB } = await import('../../main/stats-db');
- const db = new StatsDB();
- db.initialize();
-
- const result = db.checkIntegrity();
-
- expect(typeof result.ok).toBe('boolean');
- expect(Array.isArray(result.errors)).toBe(true);
- expect(result.ok).toBe(true);
- expect(result.errors.length).toBe(0);
- });
-
- it('should have correct structure for failure', async () => {
- mockDb.pragma.mockImplementation((pragma: string) => {
- if (pragma === 'integrity_check') {
- return [{ integrity_check: 'error1' }, { integrity_check: 'error2' }];
- }
- return [{ user_version: 1 }];
- });
-
- const { StatsDB } = await import('../../main/stats-db');
- const db = new StatsDB();
- db.initialize();
-
- const result = db.checkIntegrity();
-
- expect(typeof result.ok).toBe('boolean');
- expect(Array.isArray(result.errors)).toBe(true);
- expect(result.ok).toBe(false);
- expect(result.errors.length).toBe(2);
- expect(result.errors).toContain('error1');
- expect(result.errors).toContain('error2');
- });
- });
-
- describe('BackupResult type', () => {
- it('should have correct structure for success', async () => {
- mockFsExistsSync.mockReturnValue(true);
- mockFsCopyFileSync.mockImplementation(() => {});
- mockDb.pragma.mockReturnValue([{ user_version: 1 }]);
-
- const { StatsDB } = await import('../../main/stats-db');
- const db = new StatsDB();
- db.initialize();
-
- const result = db.backupDatabase();
-
- expect(typeof result.success).toBe('boolean');
- expect(result.success).toBe(true);
- expect(typeof result.backupPath).toBe('string');
- expect(result.error).toBeUndefined();
- });
-
- it('should have correct structure for failure', async () => {
- mockFsExistsSync.mockReturnValue(false);
- mockDb.pragma.mockReturnValue([{ user_version: 1 }]);
-
- const { StatsDB } = await import('../../main/stats-db');
- const db = new StatsDB();
- // Manually set initialized to test backup without full init
- (db as any).db = {};
- (db as any).initialized = true;
-
- const result = db.backupDatabase();
-
- expect(typeof result.success).toBe('boolean');
- expect(result.success).toBe(false);
- expect(result.backupPath).toBeUndefined();
- expect(typeof result.error).toBe('string');
- });
- });
-
- describe('CorruptionRecoveryResult type', () => {
- it('should be documented correctly via recovery behavior', async () => {
- // Test recovery result structure indirectly through successful recovery
- mockFsExistsSync.mockReturnValue(true);
- mockFsCopyFileSync.mockImplementation(() => {});
-
- let firstCall = true;
- mockDb.pragma.mockImplementation((pragma: string) => {
- if (pragma === 'integrity_check') {
- if (firstCall) {
- firstCall = false;
- return [{ integrity_check: 'corrupted' }];
- }
- return [{ integrity_check: 'ok' }];
- }
- return [{ user_version: 0 }];
- });
-
- const { StatsDB } = await import('../../main/stats-db');
- const db = new StatsDB();
- db.initialize();
-
- // Recovery was successful
- expect(db.isReady()).toBe(true);
- expect(mockFsCopyFileSync).toHaveBeenCalled();
- });
- });
-
- describe('exported type interfaces', () => {
- it('should export IntegrityCheckResult type', async () => {
- const { IntegrityCheckResult } = await import('../../main/stats-db');
- // TypeScript interface - existence is verified at compile time
- // We just verify the import doesn't throw
- expect(true).toBe(true);
- });
-
- it('should export BackupResult type', async () => {
- const { BackupResult } = await import('../../main/stats-db');
- expect(true).toBe(true);
- });
-
- it('should export CorruptionRecoveryResult type', async () => {
- const { CorruptionRecoveryResult } = await import('../../main/stats-db');
- expect(true).toBe(true);
- });
- });
- });
-
- // ============================================================================
- // Performance Profiling: Dashboard Load Time with 100k Events (1 Year of Data)
- // ============================================================================
-
- describe('Performance profiling: dashboard load time with 100k events', () => {
- /**
- * These tests document and verify the performance characteristics of loading
- * the Usage Dashboard with approximately 100,000 query events (~1 year of data).
- *
- * Key performance aspects tested:
- * 1. SQL query execution with indexed columns
- * 2. Aggregation computation happens in SQLite (not JavaScript)
- * 3. Result set size is compact regardless of input size
- * 4. Memory footprint remains manageable
- * 5. Individual query timing expectations
- */
-
- describe('getAggregatedStats query structure verification', () => {
- beforeEach(() => {
- vi.resetModules();
- vi.clearAllMocks();
- lastDbPath = null;
- mockDb.pragma.mockReturnValue([{ user_version: 1 }]);
- });
-
- it('should execute 6 SQL queries for aggregation (COUNT+SUM, GROUP BY agent, GROUP BY source, GROUP BY is_remote, GROUP BY date, GROUP BY hour)', async () => {
- // Track prepared statements
- const preparedQueries: string[] = [];
- mockDb.prepare.mockImplementation((sql: string) => {
- preparedQueries.push(sql.trim().replace(/\s+/g, ' '));
- return {
- get: vi.fn(() => ({ count: 100000, total_duration: 500000000 })),
- all: vi.fn(() => []),
- run: vi.fn(() => ({ changes: 1 })),
- };
- });
-
- const { StatsDB } = await import('../../main/stats-db');
- const db = new StatsDB();
- db.initialize();
- mockStatement.run.mockClear();
-
- // Clear queries captured during initialization (migrations, etc.)
- preparedQueries.length = 0;
-
- db.getAggregatedStats('year');
-
- // Verify exactly 8 queries were prepared for aggregation
- const aggregationQueries = preparedQueries.filter(
- (sql) =>
- sql.includes('query_events') &&
- !sql.includes('CREATE') &&
- !sql.includes('INSERT') &&
- !sql.includes('ALTER')
- );
-
- expect(aggregationQueries.length).toBe(8);
-
- // Query 1: Total count and sum
- expect(aggregationQueries[0]).toContain('COUNT(*)');
- expect(aggregationQueries[0]).toContain('SUM(duration)');
-
- // Query 2: Group by agent
- expect(aggregationQueries[1]).toContain('GROUP BY agent_type');
-
- // Query 3: Group by source
- expect(aggregationQueries[2]).toContain('GROUP BY source');
-
- // Query 4: Group by is_remote (location)
- expect(aggregationQueries[3]).toContain('GROUP BY is_remote');
-
- // Query 5: Group by date
- expect(aggregationQueries[4]).toContain('GROUP BY date');
-
- // Query 6: Group by agent and date (for provider usage chart)
- expect(aggregationQueries[5]).toContain('GROUP BY agent_type');
- expect(aggregationQueries[5]).toContain('date');
-
- // Query 7: Group by hour (for peak hours chart)
- expect(aggregationQueries[6]).toContain('GROUP BY hour');
-
- // Query 8: Count distinct sessions
- expect(aggregationQueries[7]).toContain('COUNT(DISTINCT session_id)');
- });
-
- it('should use indexed column (start_time) in WHERE clause for all aggregation queries', async () => {
- const preparedQueries: string[] = [];
- mockDb.prepare.mockImplementation((sql: string) => {
- preparedQueries.push(sql);
- return {
- get: vi.fn(() => ({ count: 100000, total_duration: 500000000 })),
- all: vi.fn(() => []),
- run: vi.fn(() => ({ changes: 1 })),
- };
- });
-
- const { StatsDB } = await import('../../main/stats-db');
- const db = new StatsDB();
- db.initialize();
- mockStatement.run.mockClear();
-
- // Clear queries captured during initialization (migrations, etc.)
- preparedQueries.length = 0;
-
- db.getAggregatedStats('year');
-
- // All 8 aggregation queries should filter by start_time (indexed column)
- const aggregationQueries = preparedQueries.filter(
- (sql) => sql.includes('query_events') && sql.includes('WHERE start_time')
- );
-
- expect(aggregationQueries.length).toBe(8);
- });
-
- it('should compute time range correctly for year filter (365 days)', async () => {
- let capturedStartTime: number | null = null;
- mockDb.prepare.mockImplementation(() => ({
- get: vi.fn((startTime: number) => {
- if (capturedStartTime === null) capturedStartTime = startTime;
- return { count: 100000, total_duration: 500000000 };
- }),
- all: vi.fn(() => []),
- run: vi.fn(() => ({ changes: 1 })),
- }));
-
- const { StatsDB } = await import('../../main/stats-db');
- const db = new StatsDB();
- db.initialize();
- mockStatement.run.mockClear();
-
- const beforeCall = Date.now();
- db.getAggregatedStats('year');
- const afterCall = Date.now();
-
- // Start time should be approximately 365 days ago
- const expectedStartTime = beforeCall - 365 * 24 * 60 * 60 * 1000;
- const tolerance = afterCall - beforeCall + 1000; // Allow for execution time + 1 second
-
- expect(capturedStartTime).not.toBeNull();
- expect(Math.abs(capturedStartTime! - expectedStartTime)).toBeLessThan(tolerance);
- });
- });
-
- describe('100k event simulation - result set size verification', () => {
- beforeEach(() => {
- vi.resetModules();
- vi.clearAllMocks();
- lastDbPath = null;
- mockDb.pragma.mockReturnValue([{ user_version: 1 }]);
- });
-
- it('should return compact StatsAggregation regardless of input size (100k events → ~365 day entries)', async () => {
- // Simulate 100k events over 1 year with multiple agents
- const mockByDayData = Array.from({ length: 365 }, (_, i) => {
- const date = new Date(Date.now() - (365 - i) * 24 * 60 * 60 * 1000);
- return {
- date: date.toISOString().split('T')[0],
- count: Math.floor(Math.random() * 500) + 100, // 100-600 queries per day
- duration: Math.floor(Math.random() * 1000000) + 500000, // 500-1500s per day
- };
- });
-
- mockDb.prepare.mockImplementation(() => ({
- get: vi.fn(() => ({
- count: 100000, // 100k total events
- total_duration: 500000000, // 500k seconds total
- })),
- all: vi.fn((startTime: number) => {
- // Return appropriate mock data based on query type
- // This simulates the byDay query
- return mockByDayData;
- }),
- run: vi.fn(() => ({ changes: 1 })),
- }));
-
- const { StatsDB } = await import('../../main/stats-db');
- const db = new StatsDB();
- db.initialize();
- mockStatement.run.mockClear();
-
- const result = db.getAggregatedStats('year');
-
- // Verify result structure matches StatsAggregation interface
- expect(result).toHaveProperty('totalQueries');
- expect(result).toHaveProperty('totalDuration');
- expect(result).toHaveProperty('avgDuration');
- expect(result).toHaveProperty('byAgent');
- expect(result).toHaveProperty('bySource');
- expect(result).toHaveProperty('byDay');
-
- // Verify values
- expect(result.totalQueries).toBe(100000);
- expect(result.totalDuration).toBe(500000000);
- expect(result.avgDuration).toBe(5000); // 500000000 / 100000
-
- // Verify byDay has at most 365 entries (compact result)
- expect(result.byDay.length).toBeLessThanOrEqual(365);
- });
-
- it('should produce consistent avgDuration calculation with 100k events', async () => {
- mockDb.prepare.mockImplementation(() => ({
- get: vi.fn(() => ({
- count: 100000,
- total_duration: 600000000, // 600 million ms = 6000ms average
- })),
- all: vi.fn(() => []),
- run: vi.fn(() => ({ changes: 1 })),
- }));
-
- const { StatsDB } = await import('../../main/stats-db');
- const db = new StatsDB();
- db.initialize();
- mockStatement.run.mockClear();
-
- const result = db.getAggregatedStats('year');
-
- expect(result.avgDuration).toBe(6000);
- expect(result.avgDuration).toBe(Math.round(result.totalDuration / result.totalQueries));
- });
-
- it('should handle byAgent aggregation with multiple agent types (100k events across 3 agents)', async () => {
- let queryIndex = 0;
- mockDb.prepare.mockImplementation(() => ({
- get: vi.fn(() => ({ count: 100000, total_duration: 500000000 })),
- all: vi.fn(() => {
- queryIndex++;
- // Second all() call is for byAgent
- if (queryIndex === 1) {
- return [
- { agent_type: 'claude-code', count: 70000, duration: 350000000 },
- { agent_type: 'opencode', count: 20000, duration: 100000000 },
- { agent_type: 'terminal', count: 10000, duration: 50000000 },
- ];
- }
- // Third all() call is for bySource
- if (queryIndex === 2) {
- return [
- { source: 'user', count: 60000 },
- { source: 'auto', count: 40000 },
- ];
- }
- // Fourth all() call is for byDay
- return [];
- }),
- run: vi.fn(() => ({ changes: 1 })),
- }));
-
- const { StatsDB } = await import('../../main/stats-db');
- const db = new StatsDB();
- db.initialize();
- mockStatement.run.mockClear();
-
- const result = db.getAggregatedStats('year');
-
- // Verify byAgent structure
- expect(Object.keys(result.byAgent)).toHaveLength(3);
- expect(result.byAgent['claude-code']).toEqual({ count: 70000, duration: 350000000 });
- expect(result.byAgent['opencode']).toEqual({ count: 20000, duration: 100000000 });
- expect(result.byAgent['terminal']).toEqual({ count: 10000, duration: 50000000 });
- });
-
- it('should handle bySource aggregation with mixed user/auto events (100k events)', async () => {
- let queryIndex = 0;
- mockDb.prepare.mockImplementation(() => ({
- get: vi.fn(() => ({ count: 100000, total_duration: 500000000 })),
- all: vi.fn(() => {
- queryIndex++;
- // Second call is byAgent, third is bySource
- if (queryIndex === 2) {
- return [
- { source: 'user', count: 65000 },
- { source: 'auto', count: 35000 },
- ];
- }
- return [];
- }),
- run: vi.fn(() => ({ changes: 1 })),
- }));
-
- const { StatsDB } = await import('../../main/stats-db');
- const db = new StatsDB();
- db.initialize();
- mockStatement.run.mockClear();
-
- const result = db.getAggregatedStats('year');
-
- expect(result.bySource.user).toBe(65000);
- expect(result.bySource.auto).toBe(35000);
- expect(result.bySource.user + result.bySource.auto).toBe(100000);
- });
- });
-
- describe('memory and result size constraints', () => {
- beforeEach(() => {
- vi.resetModules();
- vi.clearAllMocks();
- lastDbPath = null;
- mockDb.pragma.mockReturnValue([{ user_version: 1 }]);
- });
-
- it('should produce result set under 50KB for 100k events (compact aggregation)', async () => {
- // Maximum realistic byDay array: 365 entries
- const mockByDayData = Array.from({ length: 365 }, (_, i) => ({
- date: `2024-${String(Math.floor(i / 30) + 1).padStart(2, '0')}-${String((i % 30) + 1).padStart(2, '0')}`,
- count: 274, // ~100k / 365
- duration: 1369863, // ~500M / 365
- }));
-
- let queryIndex = 0;
- mockDb.prepare.mockImplementation(() => ({
- get: vi.fn(() => ({ count: 100000, total_duration: 500000000 })),
- all: vi.fn(() => {
- queryIndex++;
- if (queryIndex === 1) {
- return [
- { agent_type: 'claude-code', count: 80000, duration: 400000000 },
- { agent_type: 'opencode', count: 15000, duration: 75000000 },
- { agent_type: 'terminal', count: 5000, duration: 25000000 },
- ];
- }
- if (queryIndex === 2) {
- return [
- { source: 'user', count: 70000 },
- { source: 'auto', count: 30000 },
- ];
- }
- if (queryIndex === 3) {
- return mockByDayData;
- }
- return [];
- }),
- run: vi.fn(() => ({ changes: 1 })),
- }));
-
- const { StatsDB } = await import('../../main/stats-db');
- const db = new StatsDB();
- db.initialize();
- mockStatement.run.mockClear();
-
- const result = db.getAggregatedStats('year');
-
- // Estimate JSON size (this is what would be sent over IPC)
- const jsonSize = JSON.stringify(result).length;
-
- // Should be under 50KB (typically around 15-25KB)
- expect(jsonSize).toBeLessThan(50 * 1024);
-
- // More specific: should be under 30KB for typical year data
- expect(jsonSize).toBeLessThan(30 * 1024);
- });
-
- it('should not load raw 100k events into memory (aggregation happens in SQLite)', async () => {
- // This test verifies the architecture: we never call getQueryEvents
- // which would load all 100k events into memory
-
- const methodsCalled: string[] = [];
- mockDb.prepare.mockImplementation((sql: string) => {
- if (sql.includes('SELECT *')) {
- methodsCalled.push('getQueryEvents (SELECT *)');
- }
- if (sql.includes('COUNT(*)') || sql.includes('GROUP BY')) {
- methodsCalled.push('aggregation query');
- }
- return {
- get: vi.fn(() => ({ count: 100000, total_duration: 500000000 })),
- all: vi.fn(() => []),
- run: vi.fn(() => ({ changes: 1 })),
- };
- });
-
- const { StatsDB } = await import('../../main/stats-db');
- const db = new StatsDB();
- db.initialize();
- mockStatement.run.mockClear();
-
- db.getAggregatedStats('year');
-
- // Verify we used aggregation queries, not SELECT * (which loads all data)
- expect(methodsCalled.filter((m) => m.includes('aggregation'))).not.toHaveLength(0);
- expect(methodsCalled.filter((m) => m.includes('SELECT *'))).toHaveLength(0);
- });
- });
-
- describe('query performance expectations documentation', () => {
- beforeEach(() => {
- vi.resetModules();
- vi.clearAllMocks();
- lastDbPath = null;
- mockDb.pragma.mockReturnValue([{ user_version: 1 }]);
- });
-
- it('documents expected query timing for 100k events: totals query (5-20ms)', () => {
- /**
- * Query 1: SELECT COUNT(*) as count, COALESCE(SUM(duration), 0) as total_duration
- * FROM query_events WHERE start_time >= ?
- *
- * Performance characteristics:
- * - Uses idx_query_start_time index for WHERE clause
- * - Single table scan from index boundary to end
- * - COUNT and SUM are O(K) where K = rows in range
- *
- * Expected time with 100k events: 5-20ms
- * - Index seek: ~1ms
- * - Scan 100k rows for aggregation: 5-15ms
- * - SQLite's optimized aggregate functions: efficient
- */
- expect(true).toBe(true);
- });
-
- it('documents expected query timing for 100k events: byAgent query (10-30ms)', () => {
- /**
- * Query 2: SELECT agent_type, COUNT(*) as count, SUM(duration) as duration
- * FROM query_events WHERE start_time >= ?
- * GROUP BY agent_type
- *
- * Performance characteristics:
- * - Uses idx_query_start_time for filtering
- * - GROUP BY on low-cardinality column (2-10 agent types)
- * - Result set is tiny (2-10 rows)
- *
- * Expected time with 100k events: 10-30ms
- * - Index seek + 100k row scan with grouping
- * - Hash aggregation on agent_type (efficient for low cardinality)
- */
- expect(true).toBe(true);
- });
-
- it('documents expected query timing for 100k events: bySource query (5-15ms)', () => {
- /**
- * Query 3: SELECT source, COUNT(*) as count
- * FROM query_events WHERE start_time >= ?
- * GROUP BY source
- *
- * Performance characteristics:
- * - Uses idx_query_start_time for filtering
- * - GROUP BY on very low-cardinality column (only 2 values: 'user', 'auto')
- * - Result set is always 2 rows max
- *
- * Expected time with 100k events: 5-15ms
- * - Simplest grouping query
- * - Only counting, no SUM needed
- */
- expect(true).toBe(true);
- });
-
- it('documents expected query timing for 100k events: byDay query (20-50ms)', () => {
- /**
- * Query 4: SELECT date(start_time / 1000, 'unixepoch', 'localtime') as date,
- * COUNT(*) as count, SUM(duration) as duration
- * FROM query_events WHERE start_time >= ?
- * GROUP BY date(start_time / 1000, 'unixepoch', 'localtime')
- * ORDER BY date ASC
- *
- * Performance characteristics:
- * - Uses idx_query_start_time for WHERE clause
- * - date() function called for each row (most expensive operation)
- * - GROUP BY on computed column (cannot use index)
- * - Result set: max 365 rows for year range
- *
- * Expected time with 100k events: 20-50ms
- * - Date function overhead: 10-20ms
- * - Grouping 100k rows by ~365 distinct dates: 10-30ms
- * - Sorting result: <1ms (365 rows)
- */
- expect(true).toBe(true);
- });
-
- it('documents total expected dashboard load time: 55-175ms typical, 200-300ms worst case', () => {
- /**
- * Total Dashboard Load Time Analysis:
- *
- * SQL Query Execution (sequential in getAggregatedStats):
- * - Query 1 (totals): 5-20ms
- * - Query 2 (byAgent): 10-30ms
- * - Query 3 (bySource): 5-15ms
- * - Query 4 (byDay): 20-50ms
- * - Subtotal: 40-115ms
- *
- * IPC Round-trip:
- * - Electron IPC serialization: 5-10ms
- *
- * React Re-render:
- * - State update + component re-render: 10-50ms
- * - Chart rendering (Recharts/custom SVG): included
- *
- * Total Expected: 55-175ms typical
- *
- * Worst Case Scenarios:
- * - Slow disk I/O: +50-100ms
- * - CPU contention: +25-50ms
- * - Large byDay result (365 entries): +10-20ms processing
- * - Worst case total: 200-300ms
- *
- * User Experience:
- * - <100ms: Perceived as instant
- * - 100-200ms: Very responsive
- * - 200-300ms: Acceptable for modal open
- */
- expect(true).toBe(true);
- });
- });
-
- describe('index usage verification', () => {
- beforeEach(() => {
- vi.resetModules();
- vi.clearAllMocks();
- lastDbPath = null;
- mockDb.pragma.mockReturnValue([{ user_version: 1 }]);
- });
-
- it('should verify idx_query_start_time index exists in schema', async () => {
- const createStatements: string[] = [];
- mockDb.prepare.mockImplementation((sql: string) => {
- if (sql.includes('CREATE INDEX')) {
- createStatements.push(sql);
- }
- return {
- get: vi.fn(() => ({ count: 0, total_duration: 0 })),
- all: vi.fn(() => []),
- run: vi.fn(() => ({ changes: 1 })),
- };
- });
-
- // Need fresh import since user_version is 0 (new database)
- mockDb.pragma.mockReturnValue([{ user_version: 0 }]);
-
- const { StatsDB } = await import('../../main/stats-db');
- const db = new StatsDB();
- db.initialize();
-
- // Verify index creation for start_time
- const startTimeIndex = createStatements.find(
- (sql) => sql.includes('idx_query_start_time') && sql.includes('start_time')
- );
- expect(startTimeIndex).toBeDefined();
- });
-
- it('should have supporting indexes for GROUP BY columns', async () => {
- const createStatements: string[] = [];
- mockDb.prepare.mockImplementation((sql: string) => {
- if (sql.includes('CREATE INDEX')) {
- createStatements.push(sql);
- }
- return {
- get: vi.fn(() => ({ count: 0, total_duration: 0 })),
- all: vi.fn(() => []),
- run: vi.fn(() => ({ changes: 1 })),
- };
- });
-
- mockDb.pragma.mockReturnValue([{ user_version: 0 }]);
-
- const { StatsDB } = await import('../../main/stats-db');
- const db = new StatsDB();
- db.initialize();
-
- // Verify indexes on agent_type and source for faster GROUP BY
- const agentTypeIndex = createStatements.find((sql) => sql.includes('idx_query_agent_type'));
- const sourceIndex = createStatements.find((sql) => sql.includes('idx_query_source'));
-
- expect(agentTypeIndex).toBeDefined();
- expect(sourceIndex).toBeDefined();
- });
- });
-
- describe('edge cases with large datasets', () => {
- beforeEach(() => {
- vi.resetModules();
- vi.clearAllMocks();
- lastDbPath = null;
- mockDb.pragma.mockReturnValue([{ user_version: 1 }]);
- });
-
- it('should handle 100k events with 0 total duration (all queries instant)', async () => {
- mockDb.prepare.mockImplementation(() => ({
- get: vi.fn(() => ({ count: 100000, total_duration: 0 })),
- all: vi.fn(() => []),
- run: vi.fn(() => ({ changes: 1 })),
- }));
-
- const { StatsDB } = await import('../../main/stats-db');
- const db = new StatsDB();
- db.initialize();
- mockStatement.run.mockClear();
-
- const result = db.getAggregatedStats('year');
-
- expect(result.totalQueries).toBe(100000);
- expect(result.totalDuration).toBe(0);
- expect(result.avgDuration).toBe(0); // Should not divide by zero
- });
-
- it('should handle 100k events with very long durations (approaching 32-bit integer limit)', async () => {
- // Max safe integer for duration sum (100k queries × ~21k seconds each)
- const largeDuration = 2147483647; // Max 32-bit signed integer
-
- mockDb.prepare.mockImplementation(() => ({
- get: vi.fn(() => ({ count: 100000, total_duration: largeDuration })),
- all: vi.fn(() => []),
- run: vi.fn(() => ({ changes: 1 })),
- }));
-
- const { StatsDB } = await import('../../main/stats-db');
- const db = new StatsDB();
- db.initialize();
- mockStatement.run.mockClear();
-
- const result = db.getAggregatedStats('year');
-
- expect(result.totalQueries).toBe(100000);
- expect(result.totalDuration).toBe(largeDuration);
- expect(result.avgDuration).toBe(Math.round(largeDuration / 100000));
- });
-
- it('should handle sparse data (100k events concentrated in 7 days)', async () => {
- // All 100k events happened in the last week only
- const sparseByDay = Array.from({ length: 7 }, (_, i) => ({
- date: new Date(Date.now() - i * 24 * 60 * 60 * 1000).toISOString().split('T')[0],
- count: Math.floor(100000 / 7),
- duration: Math.floor(500000000 / 7),
- }));
-
- let queryIndex = 0;
- mockDb.prepare.mockImplementation(() => ({
- get: vi.fn(() => ({ count: 100000, total_duration: 500000000 })),
- all: vi.fn(() => {
- queryIndex++;
- // byDay is now the 5th query (index 4): totals, byAgent, bySource, byLocation, byDay
- if (queryIndex === 4) return sparseByDay; // byDay query
- return [];
- }),
- run: vi.fn(() => ({ changes: 1 })),
- }));
-
- const { StatsDB } = await import('../../main/stats-db');
- const db = new StatsDB();
- db.initialize();
- mockStatement.run.mockClear();
-
- const result = db.getAggregatedStats('year');
-
- // byDay should only have 7 entries despite 'year' range
- expect(result.byDay.length).toBe(7);
- expect(result.totalQueries).toBe(100000);
- });
-
- it('should handle single agent type with all 100k events', async () => {
- let queryIndex = 0;
- mockDb.prepare.mockImplementation(() => ({
- get: vi.fn(() => ({ count: 100000, total_duration: 500000000 })),
- all: vi.fn(() => {
- queryIndex++;
- if (queryIndex === 1) {
- return [{ agent_type: 'claude-code', count: 100000, duration: 500000000 }];
- }
- return [];
- }),
- run: vi.fn(() => ({ changes: 1 })),
- }));
-
- const { StatsDB } = await import('../../main/stats-db');
- const db = new StatsDB();
- db.initialize();
- mockStatement.run.mockClear();
-
- const result = db.getAggregatedStats('year');
-
- expect(Object.keys(result.byAgent)).toHaveLength(1);
- expect(result.byAgent['claude-code'].count).toBe(100000);
- });
-
- it('should handle 100% user events (no auto events)', async () => {
- let queryIndex = 0;
- mockDb.prepare.mockImplementation(() => ({
- get: vi.fn(() => ({ count: 100000, total_duration: 500000000 })),
- all: vi.fn(() => {
- queryIndex++;
- if (queryIndex === 2) {
- return [{ source: 'user', count: 100000 }];
- }
- return [];
- }),
- run: vi.fn(() => ({ changes: 1 })),
- }));
-
- const { StatsDB } = await import('../../main/stats-db');
- const db = new StatsDB();
- db.initialize();
- mockStatement.run.mockClear();
-
- const result = db.getAggregatedStats('year');
-
- expect(result.bySource.user).toBe(100000);
- expect(result.bySource.auto).toBe(0);
- });
-
- it('should handle 100% auto events (no user events)', async () => {
- let queryIndex = 0;
- mockDb.prepare.mockImplementation(() => ({
- get: vi.fn(() => ({ count: 100000, total_duration: 500000000 })),
- all: vi.fn(() => {
- queryIndex++;
- if (queryIndex === 2) {
- return [{ source: 'auto', count: 100000 }];
- }
- return [];
- }),
- run: vi.fn(() => ({ changes: 1 })),
- }));
-
- const { StatsDB } = await import('../../main/stats-db');
- const db = new StatsDB();
- db.initialize();
- mockStatement.run.mockClear();
-
- const result = db.getAggregatedStats('year');
-
- expect(result.bySource.user).toBe(0);
- expect(result.bySource.auto).toBe(100000);
- });
- });
-
- describe('parallel execution with getDatabaseSize', () => {
- beforeEach(() => {
- vi.resetModules();
- vi.clearAllMocks();
- lastDbPath = null;
- mockDb.pragma.mockReturnValue([{ user_version: 1 }]);
- });
-
- it('should support parallel execution with getDatabaseSize (like dashboard does)', async () => {
- mockDb.prepare.mockImplementation(() => ({
- get: vi.fn(() => ({ count: 100000, total_duration: 500000000 })),
- all: vi.fn(() => []),
- run: vi.fn(() => ({ changes: 1 })),
- }));
- mockFsStatSync.mockReturnValue({ size: 50 * 1024 * 1024 }); // 50MB database
-
- const { StatsDB } = await import('../../main/stats-db');
- const db = new StatsDB();
- db.initialize();
- mockStatement.run.mockClear();
-
- // Simulate parallel calls like dashboard does with Promise.all
- const [stats, dbSize] = await Promise.all([
- Promise.resolve(db.getAggregatedStats('year')),
- Promise.resolve(db.getDatabaseSize()),
- ]);
-
- expect(stats.totalQueries).toBe(100000);
- expect(dbSize).toBe(50 * 1024 * 1024);
- });
-
- it('should estimate database size for 100k events (~10-50MB)', () => {
- /**
- * Database Size Estimation for 100k query_events:
- *
- * Per-row storage (approximate):
- * - id (TEXT): ~30 bytes
- * - session_id (TEXT): ~36 bytes (UUID format)
- * - agent_type (TEXT): ~15 bytes
- * - source (TEXT): ~4 bytes
- * - start_time (INTEGER): 8 bytes
- * - duration (INTEGER): 8 bytes
- * - project_path (TEXT): ~50 bytes average
- * - tab_id (TEXT): ~36 bytes
- * - Row overhead: ~20 bytes
- *
- * Total per row: ~200 bytes
- *
- * For 100k rows: ~20MB raw data
- *
- * With indexes (4 indexes on query_events):
- * - idx_query_start_time: ~4MB
- * - idx_query_agent_type: ~2MB
- * - idx_query_source: ~1MB
- * - idx_query_session: ~4MB
- *
- * Additional tables (auto_run_sessions, auto_run_tasks): ~5-10MB
- *
- * Total estimated: 35-45MB
- * With SQLite overhead/fragmentation: 40-55MB
- *
- * After VACUUM: 30-45MB (10-20% reduction)
- */
- const estimatedRowSize = 200; // bytes
- const numRows = 100000;
- const indexOverhead = 1.5; // 50% overhead for indexes
- const sqliteOverhead = 1.2; // 20% overhead for SQLite internals
-
- const estimatedSize = numRows * estimatedRowSize * indexOverhead * sqliteOverhead;
-
- expect(estimatedSize).toBeGreaterThan(30 * 1024 * 1024); // > 30MB
- expect(estimatedSize).toBeLessThan(60 * 1024 * 1024); // < 60MB
- });
- });
-
- describe('comparison: exportCsv vs getAggregatedStats with 100k events', () => {
- beforeEach(() => {
- vi.resetModules();
- vi.clearAllMocks();
- lastDbPath = null;
- mockDb.pragma.mockReturnValue([{ user_version: 1 }]);
- });
-
- it('documents performance difference: exportCsv loads all rows (slow) vs getAggregatedStats (fast)', () => {
- /**
- * exportCsv() Performance with 100k events:
- *
- * Query: SELECT * FROM query_events WHERE start_time >= ?
- *
- * - Loads ALL 100k rows into memory
- * - JavaScript processes each row for CSV formatting
- * - Creates ~10MB string in memory
- *
- * Expected time: 500-2000ms
- * Memory impact: ~10-20MB spike
- *
- * vs
- *
- * getAggregatedStats() Performance with 100k events:
- *
- * - 4 aggregate queries (no row loading)
- * - SQLite computes COUNT, SUM, GROUP BY
- * - Result is ~10-20KB
- *
- * Expected time: 55-175ms
- * Memory impact: minimal (~20KB)
- *
- * Conclusion: Dashboard uses getAggregatedStats (fast path).
- * Export only used on-demand when user explicitly exports.
- */
- expect(true).toBe(true);
- });
-
- it('should not use exportCsv for dashboard load (verify separate code paths)', async () => {
- const methodsCalled: string[] = [];
-
- mockDb.prepare.mockImplementation((sql: string) => {
- if (sql.includes('SELECT *')) {
- methodsCalled.push('exportCsv_pattern');
- }
- if (sql.includes('COUNT(*)') || sql.includes('GROUP BY')) {
- methodsCalled.push('getAggregatedStats_pattern');
- }
- return {
- get: vi.fn(() => ({ count: 100000, total_duration: 500000000 })),
- all: vi.fn(() => []),
- run: vi.fn(() => ({ changes: 1 })),
- };
- });
-
- const { StatsDB } = await import('../../main/stats-db');
- const db = new StatsDB();
- db.initialize();
- mockStatement.run.mockClear();
-
- // Dashboard load path
- db.getAggregatedStats('year');
-
- expect(methodsCalled).toContain('getAggregatedStats_pattern');
- expect(methodsCalled).not.toContain('exportCsv_pattern');
- });
- });
- });
-
- // ============================================================================
- // EXPLAIN QUERY PLAN Verification for SQL Query Optimization
- // ============================================================================
-
- describe('EXPLAIN QUERY PLAN verification for SQL query optimization', () => {
- /**
- * These tests document and verify the query execution plans for all SQL queries
- * used in the StatsDB module. EXPLAIN QUERY PLAN (EQP) provides insight into
- * how SQLite will execute a query, including:
- *
- * - Which indexes will be used
- * - The scan order (SEARCH vs SCAN)
- * - Join strategies for multi-table queries
- * - Temporary B-tree usage for sorting/grouping
- *
- * Key EQP terminology:
- * - SEARCH: Uses an index (fast, O(log N))
- * - SCAN: Full table scan (slow, O(N))
- * - USING INDEX: Specifies which index is used
- * - USING COVERING INDEX: Index contains all needed columns (fastest)
- * - TEMP B-TREE: Temporary structure for ORDER BY/GROUP BY
- *
- * Optimization targets:
- * 1. All WHERE clauses on indexed columns should show SEARCH
- * 2. Avoid SCAN on large tables
- * 3. GROUP BY on indexed columns should use index
- * 4. Minimize TEMP B-TREE usage for sorting
- */
-
- describe('getQueryEvents query plan', () => {
- beforeEach(() => {
- vi.resetModules();
- vi.clearAllMocks();
- lastDbPath = null;
- mockDb.pragma.mockReturnValue([{ user_version: 1 }]);
- });
-
- it('documents expected EQP for basic getQueryEvents (no filters)', () => {
- /**
- * Query: SELECT * FROM query_events WHERE start_time >= ? ORDER BY start_time DESC
- *
- * Expected EXPLAIN QUERY PLAN:
- * | id | parent | notused | detail |
- * |----|--------|---------|---------------------------------------------------|
- * | 2 | 0 | 0 | SEARCH query_events USING INDEX idx_query_start_time (start_time>?) |
- *
- * Analysis:
- * - SEARCH: Uses idx_query_start_time index (not full table scan)
- * - Order matches index order (no extra sort needed)
- * - Performance: O(log N) seek + O(K) scan where K = rows in range
- *
- * This is optimal - no changes needed.
- */
- expect(true).toBe(true);
- });
-
- it('documents expected EQP for getQueryEvents with agentType filter', () => {
- /**
- * Query: SELECT * FROM query_events WHERE start_time >= ? AND agent_type = ? ORDER BY start_time DESC
- *
- * Expected EXPLAIN QUERY PLAN:
- * | id | parent | notused | detail |
- * |----|--------|---------|---------------------------------------------------|
- * | 2 | 0 | 0 | SEARCH query_events USING INDEX idx_query_start_time (start_time>?) |
- *
- * Analysis:
- * - Uses idx_query_start_time for range filter
- * - agent_type = ? is evaluated as a post-filter (not using idx_query_agent_type)
- * - SQLite optimizer chooses start_time index because it provides ORDER BY for free
- *
- * Optimization consideration:
- * - A composite index (start_time, agent_type) could speed up this query
- * - However, the single-column index is sufficient for typical use cases
- * - Adding composite indexes increases write overhead
- *
- * Current performance is acceptable. No changes recommended unless
- * performance issues arise with very large datasets and frequent agent_type filtering.
- */
- expect(true).toBe(true);
- });
-
- it('documents expected EQP for getQueryEvents with source filter', () => {
- /**
- * Query: SELECT * FROM query_events WHERE start_time >= ? AND source = ? ORDER BY start_time DESC
- *
- * Expected EXPLAIN QUERY PLAN:
- * | id | parent | notused | detail |
- * |----|--------|---------|---------------------------------------------------|
- * | 2 | 0 | 0 | SEARCH query_events USING INDEX idx_query_start_time (start_time>?) |
- *
- * Analysis:
- * - source has only 2 values ('user', 'auto'), very low cardinality
- * - idx_query_source exists but filtering by start_time first is usually better
- * - Post-filter on source is efficient due to low cardinality
- *
- * This is optimal for the query pattern.
- */
- expect(true).toBe(true);
- });
-
- it('documents expected EQP for getQueryEvents with projectPath filter', () => {
- /**
- * Query: SELECT * FROM query_events WHERE start_time >= ? AND project_path = ? ORDER BY start_time DESC
- *
- * Expected EXPLAIN QUERY PLAN:
- * | id | parent | notused | detail |
- * |----|--------|---------|---------------------------------------------------|
- * | 2 | 0 | 0 | SEARCH query_events USING INDEX idx_query_start_time (start_time>?) |
- *
- * Analysis:
- * - project_path has no dedicated index (intentional - high cardinality, rarely filtered)
- * - Uses start_time index, post-filters on project_path
- * - For per-project dashboards, a future optimization could add idx_query_project_path
- *
- * Current implementation is sufficient. Monitor if project_path filtering becomes common.
- */
- expect(true).toBe(true);
- });
-
- it('documents expected EQP for getQueryEvents with sessionId filter', () => {
- /**
- * Query: SELECT * FROM query_events WHERE start_time >= ? AND session_id = ? ORDER BY start_time DESC
- *
- * Expected EXPLAIN QUERY PLAN (could vary based on data distribution):
- * | id | parent | notused | detail |
- * |----|--------|---------|---------------------------------------------------|
- * | 2 | 0 | 0 | SEARCH query_events USING INDEX idx_query_session (session_id=?) |
- *
- * OR (if optimizer prefers start_time):
- * | 2 | 0 | 0 | SEARCH query_events USING INDEX idx_query_start_time (start_time>?) |
- *
- * Analysis:
- * - idx_query_session exists specifically for session-based lookups
- * - SQLite optimizer may choose either index depending on:
- * - Estimated selectivity of session_id vs start_time
- * - Whether ORDER BY can be satisfied by index
- *
- * Both plans are efficient. The session index is important for
- * per-session history lookups which don't filter by time.
- */
- expect(true).toBe(true);
- });
-
- it('verifies getQueryEvents query uses start_time filter (enables index usage)', async () => {
- const queriesExecuted: string[] = [];
- mockDb.prepare.mockImplementation((sql: string) => {
- queriesExecuted.push(sql);
- return {
- get: vi.fn(() => ({ count: 0, total_duration: 0 })),
- all: vi.fn(() => []),
- run: vi.fn(() => ({ changes: 1 })),
- };
- });
-
- const { StatsDB } = await import('../../main/stats-db');
- const db = new StatsDB();
- db.initialize();
- mockStatement.run.mockClear();
-
- db.getQueryEvents('week');
-
- const selectQuery = queriesExecuted.find(
- (sql) => sql.includes('SELECT *') && sql.includes('query_events')
- );
- expect(selectQuery).toBeDefined();
- expect(selectQuery).toContain('WHERE start_time >=');
- expect(selectQuery).toContain('ORDER BY start_time DESC');
- });
- });
-
- describe('getAggregatedStats query plans', () => {
- beforeEach(() => {
- vi.resetModules();
- vi.clearAllMocks();
- lastDbPath = null;
- mockDb.pragma.mockReturnValue([{ user_version: 1 }]);
- });
-
- it('documents expected EQP for totals query (COUNT + SUM)', () => {
- /**
- * Query: SELECT COUNT(*) as count, COALESCE(SUM(duration), 0) as total_duration
- * FROM query_events WHERE start_time >= ?
- *
- * Expected EXPLAIN QUERY PLAN:
- * | id | parent | notused | detail |
- * |----|--------|---------|---------------------------------------------------|
- * | 2 | 0 | 0 | SEARCH query_events USING INDEX idx_query_start_time (start_time>?) |
- *
- * Analysis:
- * - Uses idx_query_start_time for range scan
- * - COUNT(*) and SUM(duration) are computed during single pass
- * - No sorting or grouping required
- * - Performance: O(log N) + O(K) where K = rows in range
- *
- * This is optimal for aggregate queries with range filter.
- */
- expect(true).toBe(true);
- });
-
- it('documents expected EQP for byAgent query (GROUP BY agent_type)', () => {
- /**
- * Query: SELECT agent_type, COUNT(*) as count, SUM(duration) as duration
- * FROM query_events WHERE start_time >= ?
- * GROUP BY agent_type
- *
- * Expected EXPLAIN QUERY PLAN:
- * | id | parent | notused | detail |
- * |----|--------|---------|---------------------------------------------------|
- * | 2 | 0 | 0 | SEARCH query_events USING INDEX idx_query_start_time (start_time>?) |
- * | 4 | 0 | 0 | USE TEMP B-TREE FOR GROUP BY |
- *
- * Analysis:
- * - SEARCH: Uses start_time index for filtering
- * - TEMP B-TREE: Required for GROUP BY (expected, not a problem)
- * - agent_type has ~3-5 distinct values, so grouping is very fast
- *
- * Optimization alternatives considered:
- * 1. Composite index (agent_type, start_time): Would allow index-based grouping
- * but increases storage and write overhead. Not recommended.
- * 2. Materialized view: Overkill for this use case.
- *
- * Current implementation is efficient. TEMP B-TREE for 3-5 groups is negligible.
- */
- expect(true).toBe(true);
- });
-
- it('documents expected EQP for bySource query (GROUP BY source)', () => {
- /**
- * Query: SELECT source, COUNT(*) as count
- * FROM query_events WHERE start_time >= ?
- * GROUP BY source
- *
- * Expected EXPLAIN QUERY PLAN:
- * | id | parent | notused | detail |
- * |----|--------|---------|---------------------------------------------------|
- * | 2 | 0 | 0 | SEARCH query_events USING INDEX idx_query_start_time (start_time>?) |
- * | 4 | 0 | 0 | USE TEMP B-TREE FOR GROUP BY |
- *
- * Analysis:
- * - Identical pattern to byAgent query
- * - source has only 2 values, so TEMP B-TREE is trivial
- * - This is the simplest grouping query in the set
- *
- * No optimization needed. Already optimal for the use case.
- */
- expect(true).toBe(true);
- });
-
- it('documents expected EQP for byDay query (GROUP BY computed date)', () => {
- /**
- * Query: SELECT date(start_time / 1000, 'unixepoch', 'localtime') as date,
- * COUNT(*) as count, SUM(duration) as duration
- * FROM query_events WHERE start_time >= ?
- * GROUP BY date(start_time / 1000, 'unixepoch', 'localtime')
- * ORDER BY date ASC
- *
- * Expected EXPLAIN QUERY PLAN:
- * | id | parent | notused | detail |
- * |----|--------|---------|---------------------------------------------------|
- * | 2 | 0 | 0 | SEARCH query_events USING INDEX idx_query_start_time (start_time>?) |
- * | 4 | 0 | 0 | USE TEMP B-TREE FOR GROUP BY |
- * | 6 | 0 | 0 | USE TEMP B-TREE FOR ORDER BY |
- *
- * Analysis:
- * - SEARCH: Uses start_time index for WHERE clause
- * - TEMP B-TREE for GROUP BY: Required because date() is a computed column
- * - TEMP B-TREE for ORDER BY: Required because grouping output isn't sorted
- *
- * This is the most expensive query in getAggregatedStats because:
- * 1. date() function must be evaluated for each row
- * 2. Grouping is on computed value (can't use index)
- * 3. Sorting of results requires additional TEMP B-TREE
- *
- * Optimization alternatives considered:
- * 1. Stored computed column + index: Would require schema change
- * 2. Pre-aggregated day-level table: Adds complexity, not justified
- * 3. Application-level date grouping: Would load all rows into memory
- *
- * Current implementation is the best balance of simplicity and performance.
- * The date() function overhead is acceptable for dashboard use.
- */
- expect(true).toBe(true);
- });
-
- it('verifies all getAggregatedStats queries filter by start_time', async () => {
- const queriesExecuted: string[] = [];
- mockDb.prepare.mockImplementation((sql: string) => {
- queriesExecuted.push(sql);
- return {
- get: vi.fn(() => ({ count: 100, total_duration: 50000 })),
- all: vi.fn(() => []),
- run: vi.fn(() => ({ changes: 1 })),
- };
- });
-
- const { StatsDB } = await import('../../main/stats-db');
- const db = new StatsDB();
- db.initialize();
- mockStatement.run.mockClear();
-
- // Clear queries captured during initialization (migrations, etc.)
- queriesExecuted.length = 0;
-
- db.getAggregatedStats('month');
-
- // Find all SELECT queries on query_events (excluding ALTER statements from migrations)
- const aggregationQueries = queriesExecuted.filter(
- (sql) =>
- sql.includes('query_events') &&
- (sql.includes('COUNT') || sql.includes('GROUP BY')) &&
- !sql.includes('ALTER')
- );
-
- expect(aggregationQueries.length).toBe(8);
-
- // All should filter by start_time (enables index usage)
- for (const query of aggregationQueries) {
- expect(query).toContain('WHERE start_time >=');
- }
- });
- });
-
- describe('getAutoRunSessions query plan', () => {
- beforeEach(() => {
- vi.resetModules();
- vi.clearAllMocks();
- lastDbPath = null;
- mockDb.pragma.mockReturnValue([{ user_version: 1 }]);
- });
-
- it('documents expected EQP for getAutoRunSessions', () => {
- /**
- * Query: SELECT * FROM auto_run_sessions WHERE start_time >= ? ORDER BY start_time DESC
- *
- * Expected EXPLAIN QUERY PLAN:
- * | id | parent | notused | detail |
- * |----|--------|---------|---------------------------------------------------|
- * | 2 | 0 | 0 | SEARCH auto_run_sessions USING INDEX idx_auto_session_start (start_time>?) |
- *
- * Analysis:
- * - SEARCH: Uses idx_auto_session_start index
- * - Index order matches query ORDER BY
- * - Simple and efficient query plan
- *
- * This is optimal. No changes needed.
- */
- expect(true).toBe(true);
- });
-
- it('verifies getAutoRunSessions query structure', async () => {
- const queriesExecuted: string[] = [];
- mockDb.prepare.mockImplementation((sql: string) => {
- queriesExecuted.push(sql);
- return {
- get: vi.fn(() => ({})),
- all: vi.fn(() => []),
- run: vi.fn(() => ({ changes: 1 })),
- };
- });
-
- const { StatsDB } = await import('../../main/stats-db');
- const db = new StatsDB();
- db.initialize();
- mockStatement.run.mockClear();
-
- db.getAutoRunSessions('week');
-
- const selectQuery = queriesExecuted.find(
- (sql) => sql.includes('SELECT *') && sql.includes('auto_run_sessions')
- );
- expect(selectQuery).toBeDefined();
- expect(selectQuery).toContain('WHERE start_time >=');
- expect(selectQuery).toContain('ORDER BY start_time DESC');
- });
- });
-
- describe('getAutoRunTasks query plan', () => {
- beforeEach(() => {
- vi.resetModules();
- vi.clearAllMocks();
- lastDbPath = null;
- mockDb.pragma.mockReturnValue([{ user_version: 1 }]);
- });
-
- it('documents expected EQP for getAutoRunTasks', () => {
- /**
- * Query: SELECT * FROM auto_run_tasks WHERE auto_run_session_id = ? ORDER BY task_index ASC
- *
- * Expected EXPLAIN QUERY PLAN:
- * | id | parent | notused | detail |
- * |----|--------|---------|---------------------------------------------------|
- * | 2 | 0 | 0 | SEARCH auto_run_tasks USING INDEX idx_task_auto_session (auto_run_session_id=?) |
- * | 4 | 0 | 0 | USE TEMP B-TREE FOR ORDER BY |
- *
- * Analysis:
- * - SEARCH: Uses idx_task_auto_session for equality lookup
- * - TEMP B-TREE: Needed for ORDER BY task_index (not covered by index)
- * - Each session has ~5-20 tasks, so sorting overhead is minimal
- *
- * Optimization alternative:
- * - Composite index (auto_run_session_id, task_index) would eliminate TEMP B-TREE
- * - However, the benefit is negligible for small task counts
- *
- * Current implementation is efficient. No changes recommended.
- */
- expect(true).toBe(true);
- });
-
- it('verifies getAutoRunTasks uses session_id index', async () => {
- const queriesExecuted: string[] = [];
- mockDb.prepare.mockImplementation((sql: string) => {
- queriesExecuted.push(sql);
- return {
- get: vi.fn(() => ({})),
- all: vi.fn(() => []),
- run: vi.fn(() => ({ changes: 1 })),
- };
- });
-
- const { StatsDB } = await import('../../main/stats-db');
- const db = new StatsDB();
- db.initialize();
- mockStatement.run.mockClear();
-
- db.getAutoRunTasks('session-123');
-
- const selectQuery = queriesExecuted.find(
- (sql) => sql.includes('SELECT *') && sql.includes('auto_run_tasks')
- );
- expect(selectQuery).toBeDefined();
- expect(selectQuery).toContain('WHERE auto_run_session_id =');
- expect(selectQuery).toContain('ORDER BY task_index ASC');
- });
- });
-
- describe('INSERT query plans', () => {
- beforeEach(() => {
- vi.resetModules();
- vi.clearAllMocks();
- lastDbPath = null;
- mockDb.pragma.mockReturnValue([{ user_version: 1 }]);
- });
-
- it('documents expected insert performance characteristics', () => {
- /**
- * INSERT queries are generally fast in SQLite with these considerations:
- *
- * insertQueryEvent:
- * - INSERT INTO query_events (id, session_id, agent_type, source, start_time, duration, project_path, tab_id)
- * - 4 indexes to update: idx_query_start_time, idx_query_agent_type, idx_query_source, idx_query_session
- * - Expected time: <1ms per insert
- *
- * insertAutoRunSession:
- * - INSERT INTO auto_run_sessions (...)
- * - 1 index to update: idx_auto_session_start
- * - Expected time: <1ms per insert
- *
- * insertAutoRunTask:
- * - INSERT INTO auto_run_tasks (...)
- * - 2 indexes to update: idx_task_auto_session, idx_task_start
- * - Expected time: <1ms per insert
- *
- * WAL mode benefits:
- * - Writes don't block reads
- * - Batch inserts are efficient (single WAL flush)
- * - Checkpointing happens automatically
- *
- * No optimization needed for write performance.
- */
- expect(true).toBe(true);
- });
-
- it('verifies WAL mode is enabled for concurrent access', async () => {
- let pragmaCalls: string[] = [];
- mockDb.pragma.mockImplementation((pragmaStr: string) => {
- pragmaCalls.push(pragmaStr);
- if (pragmaStr.includes('user_version')) {
- return [{ user_version: 1 }];
- }
- if (pragmaStr.includes('journal_mode')) {
- return [{ journal_mode: 'wal' }];
- }
- return [];
- });
-
- const { StatsDB } = await import('../../main/stats-db');
- const db = new StatsDB();
- db.initialize();
-
- // Verify WAL mode was set during initialization
- expect(pragmaCalls).toContain('journal_mode = WAL');
- });
- });
-
- describe('DELETE/UPDATE query plans', () => {
- beforeEach(() => {
- vi.resetModules();
- vi.clearAllMocks();
- lastDbPath = null;
- mockDb.pragma.mockReturnValue([{ user_version: 1 }]);
- });
-
- it('documents expected EQP for clearOldData DELETE queries', () => {
- /**
- * clearOldData executes multiple DELETE queries:
- *
- * Query 1: DELETE FROM auto_run_tasks WHERE auto_run_session_id IN
- * (SELECT id FROM auto_run_sessions WHERE start_time < ?)
- *
- * Expected EXPLAIN QUERY PLAN:
- * | id | parent | notused | detail |
- * |----|--------|---------|---------------------------------------------------|
- * | 2 | 0 | 0 | SEARCH auto_run_tasks USING INDEX idx_task_auto_session (auto_run_session_id=?) |
- * | 5 | 2 | 0 | SEARCH auto_run_sessions USING INDEX idx_auto_session_start (start_time) |
- *
- * Query 2: DELETE FROM auto_run_sessions WHERE start_time < ?
- *
- * Expected EXPLAIN QUERY PLAN:
- * | id | parent | notused | detail |
- * |----|--------|---------|---------------------------------------------------|
- * | 2 | 0 | 0 | SEARCH auto_run_sessions USING INDEX idx_auto_session_start (start_time) |
- *
- * Query 3: DELETE FROM query_events WHERE start_time < ?
- *
- * Expected EXPLAIN QUERY PLAN:
- * | id | parent | notused | detail |
- * |----|--------|---------|---------------------------------------------------|
- * | 2 | 0 | 0 | SEARCH query_events USING INDEX idx_query_start_time (start_time) |
- *
- * Analysis:
- * - All DELETEs use index-based range scans (efficient)
- * - Cascading delete pattern (tasks → sessions → events) is correct
- * - Index maintenance after DELETE is O(log N) per row deleted
- *
- * Performance with 100k rows, clearing 90 days old:
- * - ~25k rows affected
- * - Expected time: 500-2000ms (index updates are the bottleneck)
- * - Followed by implicit WAL checkpoint
- */
- expect(true).toBe(true);
- });
-
- it('documents expected EQP for updateAutoRunSession', () => {
- /**
- * Query: UPDATE auto_run_sessions SET duration = ?, tasks_total = ?, tasks_completed = ?
- * WHERE id = ?
- *
- * Expected EXPLAIN QUERY PLAN:
- * | id | parent | notused | detail |
- * |----|--------|---------|---------------------------------------------------|
- * | 2 | 0 | 0 | SEARCH auto_run_sessions USING INTEGER PRIMARY KEY (rowid=?) |
- *
- * Analysis:
- * - Uses PRIMARY KEY for O(1) lookup
- * - Only updates non-indexed columns (no index maintenance)
- * - Expected time: <1ms
- *
- * This is optimal.
- */
- expect(true).toBe(true);
- });
-
- it('verifies clearOldData uses indexed columns for deletion', async () => {
- const queriesExecuted: string[] = [];
- mockDb.prepare.mockImplementation((sql: string) => {
- queriesExecuted.push(sql);
- return {
- get: vi.fn(() => ({})),
- all: vi.fn(() => []),
- run: vi.fn(() => ({ changes: 10 })),
- };
- });
-
- const { StatsDB } = await import('../../main/stats-db');
- const db = new StatsDB();
- db.initialize();
- mockStatement.run.mockClear();
-
- db.clearOldData(30);
-
- // All DELETE queries should filter by indexed time columns
- // (start_time for query_events, auto_run_sessions, auto_run_tasks;
- // created_at for session_lifecycle)
- const deleteQueries = queriesExecuted.filter((sql) => sql.includes('DELETE'));
- expect(deleteQueries.length).toBeGreaterThan(0);
-
- for (const query of deleteQueries) {
- const usesIndexedTimeColumn =
- query.includes('start_time <') || query.includes('created_at <');
- expect(usesIndexedTimeColumn).toBe(true);
- }
- });
- });
-
- describe('index coverage analysis', () => {
- beforeEach(() => {
- vi.resetModules();
- vi.clearAllMocks();
- lastDbPath = null;
- mockDb.pragma.mockReturnValue([{ user_version: 0 }]);
- });
-
- it('documents all indexes and their coverage', () => {
- /**
- * Index Coverage Analysis for StatsDB:
- *
- * query_events table (primary read/write table):
- * ┌─────────────────────────────┬─────────────────────────────────────────────────┐
- * │ Index │ Columns Covered │
- * ├─────────────────────────────┼─────────────────────────────────────────────────┤
- * │ idx_query_start_time │ start_time │
- * │ idx_query_agent_type │ agent_type │
- * │ idx_query_source │ source │
- * │ idx_query_session │ session_id │
- * └─────────────────────────────┴─────────────────────────────────────────────────┘
- *
- * auto_run_sessions table:
- * ┌─────────────────────────────┬─────────────────────────────────────────────────┐
- * │ Index │ Columns Covered │
- * ├─────────────────────────────┼─────────────────────────────────────────────────┤
- * │ idx_auto_session_start │ start_time │
- * └─────────────────────────────┴─────────────────────────────────────────────────┘
- *
- * auto_run_tasks table:
- * ┌─────────────────────────────┬─────────────────────────────────────────────────┐
- * │ Index │ Columns Covered │
- * ├─────────────────────────────┼─────────────────────────────────────────────────┤
- * │ idx_task_auto_session │ auto_run_session_id │
- * │ idx_task_start │ start_time │
- * └─────────────────────────────┴─────────────────────────────────────────────────┘
- *
- * Query Pattern Coverage:
- * - Time-range queries: ✓ All tables have start_time indexes
- * - Session lookups: ✓ query_events has session_id index
- * - Task lookups by session: ✓ auto_run_tasks has auto_run_session_id index
- * - Agent filtering: ✓ query_events has agent_type index (post-filter)
- * - Source filtering: ✓ query_events has source index (post-filter)
- *
- * Missing indexes (intentional):
- * - project_path: Low selectivity, rarely filtered alone
- * - tab_id: Very rare to filter by tab
- * - document_path: Low selectivity
- * - task_content: Full-text search not supported
- */
- expect(true).toBe(true);
- });
-
- it('verifies all expected indexes are created during initialization', async () => {
- const createIndexStatements: string[] = [];
- mockDb.prepare.mockImplementation((sql: string) => {
- if (sql.includes('CREATE INDEX')) {
- createIndexStatements.push(sql);
- }
- return {
- get: vi.fn(() => ({})),
- all: vi.fn(() => []),
- run: vi.fn(() => ({ changes: 1 })),
- };
- });
-
- const { StatsDB } = await import('../../main/stats-db');
- const db = new StatsDB();
- db.initialize();
-
- // Verify all expected indexes
- const expectedIndexes = [
- 'idx_query_start_time',
- 'idx_query_agent_type',
- 'idx_query_source',
- 'idx_query_session',
- 'idx_auto_session_start',
- 'idx_task_auto_session',
- 'idx_task_start',
- ];
-
- for (const indexName of expectedIndexes) {
- const found = createIndexStatements.some((sql) => sql.includes(indexName));
- expect(found).toBe(true);
- }
- });
- });
-
- describe('potential slow queries identified and mitigations', () => {
- beforeEach(() => {
- vi.resetModules();
- vi.clearAllMocks();
- lastDbPath = null;
- mockDb.pragma.mockReturnValue([{ user_version: 1 }]);
- });
-
- it('identifies byDay query as the slowest (date() function overhead)', () => {
- /**
- * IDENTIFIED SLOW QUERY:
- *
- * SELECT date(start_time / 1000, 'unixepoch', 'localtime') as date,
- * COUNT(*) as count, SUM(duration) as duration
- * FROM query_events WHERE start_time >= ?
- * GROUP BY date(start_time / 1000, 'unixepoch', 'localtime')
- * ORDER BY date ASC
- *
- * Why it's slow:
- * 1. date() function evaluated for EVERY row (~100k calls for year range)
- * 2. GROUP BY on computed value requires TEMP B-TREE
- * 3. ORDER BY requires another TEMP B-TREE
- *
- * Measured impact: 20-50ms for 100k rows (vs 5-15ms for simpler aggregations)
- *
- * MITIGATION STATUS: ACCEPTED
- *
- * This is acceptable because:
- * - Dashboard loads are infrequent (user manually opens modal)
- * - 50ms is imperceptible to users
- * - Optimizing would require schema changes (stored computed column)
- * - The complexity cost outweighs the performance benefit
- *
- * If this becomes a bottleneck in the future, consider:
- * 1. Pre-computed daily_stats table updated on each insert
- * 2. Batch updates to computed column during idle time
- * 3. Client-side date grouping (trades memory for speed)
- */
- expect(true).toBe(true);
- });
-
- it('identifies exportToCsv as potentially slow with large datasets', () => {
- /**
- * IDENTIFIED SLOW QUERY:
- *
- * getQueryEvents (used by exportToCsv):
- * SELECT * FROM query_events WHERE start_time >= ? ORDER BY start_time DESC
- *
- * Why it's slow for large datasets:
- * 1. SELECT * loads ALL columns into memory
- * 2. 100k rows × 200 bytes/row = 20MB memory allocation
- * 3. JavaScript string processing for CSV formatting
- *
- * Measured impact: 500-2000ms for 100k rows
- *
- * MITIGATION STATUS: ACCEPTED WITH MONITORING
- *
- * This is acceptable because:
- * - Export is an explicit user action (not affecting dashboard performance)
- * - Users expect exports to take time
- * - Streaming export would require significant refactoring
- *
- * If this becomes problematic, consider:
- * 1. Pagination with progress indicator
- * 2. Background export with notification when complete
- * 3. Direct SQLite export to file (bypassing JavaScript)
- */
- expect(true).toBe(true);
- });
-
- it('identifies clearOldData as potentially slow for bulk deletions', () => {
- /**
- * IDENTIFIED SLOW OPERATION:
- *
- * clearOldData with large deletion count:
- * - DELETE FROM query_events WHERE start_time < ?
- * - Each deleted row requires index updates (4 indexes)
- *
- * Measured impact: 500-2000ms for 25k row deletion
- *
- * MITIGATION STATUS: ACCEPTED WITH UI FEEDBACK
- *
- * Current implementation is acceptable because:
- * - Deletion is explicit user action in Settings
- * - UI shows success/error feedback after completion
- * - WAL mode prevents blocking other operations
- *
- * If this becomes problematic, consider:
- * 1. Batch deletion with progress indicator
- * 2. Background deletion with notification
- * 3. VACUUM after large deletions (already implemented conditional on size)
- */
- expect(true).toBe(true);
- });
-
- it('confirms no full table scans (SCAN) in production queries', async () => {
- /**
- * VERIFICATION: All production queries use SEARCH (index-based) access.
- *
- * Queries verified:
- * - getQueryEvents: SEARCH via idx_query_start_time
- * - getAggregatedStats (all 4 queries): SEARCH via idx_query_start_time
- * - getAutoRunSessions: SEARCH via idx_auto_session_start
- * - getAutoRunTasks: SEARCH via idx_task_auto_session
- * - clearOldData (all DELETEs): SEARCH via respective start_time indexes
- *
- * NO queries require SCAN (full table scan).
- * All WHERE clauses filter on indexed columns.
- */
- const queriesExecuted: string[] = [];
- mockDb.prepare.mockImplementation((sql: string) => {
- queriesExecuted.push(sql);
- return {
- get: vi.fn(() => ({ count: 100, total_duration: 50000 })),
- all: vi.fn(() => []),
- run: vi.fn(() => ({ changes: 1 })),
- };
- });
-
- const { StatsDB } = await import('../../main/stats-db');
- const db = new StatsDB();
- db.initialize();
- mockStatement.run.mockClear();
-
- // Execute all query methods
- db.getQueryEvents('year');
- db.getAggregatedStats('year');
- db.getAutoRunSessions('year');
- db.getAutoRunTasks('session-1');
-
- // All SELECT queries should have WHERE clause on indexed column
- const selectQueries = queriesExecuted.filter(
- (sql) => sql.includes('SELECT') && !sql.includes('CREATE')
- );
-
- for (const query of selectQueries) {
- // Each query should filter by an indexed column
- // (includes created_at for session_lifecycle table)
- const hasIndexedFilter =
- query.includes('start_time') ||
- query.includes('created_at') ||
- query.includes('auto_run_session_id') ||
- query.includes('session_id');
- expect(hasIndexedFilter).toBe(true);
- }
- });
- });
-
- describe('optimization recommendations summary', () => {
- it('documents optimization decisions and rationale', () => {
- /**
- * OPTIMIZATION SUMMARY FOR StatsDB
- * ================================
- *
- * IMPLEMENTED OPTIMIZATIONS:
- *
- * 1. Index on start_time for all time-range queries
- * - All primary queries filter by time range
- * - O(log N) seek instead of O(N) scan
- *
- * 2. WAL (Write-Ahead Logging) mode
- * - Concurrent reads during writes
- * - Better crash recovery
- * - Improved write performance
- *
- * 3. Aggregation in SQLite (not JavaScript)
- * - COUNT, SUM, GROUP BY computed in database
- * - Minimal data transfer to renderer process
- * - Result set is ~20KB regardless of input size
- *
- * 4. Conditional VACUUM on startup
- * - Only runs if database > 100MB
- * - Reclaims space after deletions
- * - Improves query performance
- *
- * 5. Single-column indexes on low-cardinality columns
- * - agent_type, source, session_id
- * - Enable efficient post-filtering
- * - Minimal storage overhead
- *
- * CONSIDERED BUT NOT IMPLEMENTED:
- *
- * 1. Composite indexes (e.g., start_time + agent_type)
- * - Would speed up filtered aggregations marginally
- * - Increases storage and write overhead
- * - Current performance is acceptable
- *
- * 2. Materialized views for aggregations
- * - Pre-computed daily/weekly stats
- * - Adds complexity for minimal benefit
- * - Real-time aggregation is fast enough
- *
- * 3. Covering indexes
- * - Include all columns in index for index-only scans
- * - Significantly increases storage
- * - Current performance doesn't justify
- *
- * 4. Stored computed columns (e.g., date_only)
- * - Would speed up byDay query
- * - Requires schema change
- * - Current 50ms is acceptable
- *
- * 5. Table partitioning by time
- * - SQLite doesn't support native partitioning
- * - Multiple tables would complicate queries
- * - Not needed for expected data volumes
- *
- * MONITORING RECOMMENDATIONS:
- *
- * 1. Track getAggregatedStats execution time in production
- * 2. Alert if dashboard load exceeds 500ms
- * 3. Monitor database file size growth
- * 4. Consider index on project_path if per-project dashboards are added
- */
- expect(true).toBe(true);
- });
- });
- });
+ // =====================================================================
});
diff --git a/src/__tests__/renderer/components/AboutModal.test.tsx b/src/__tests__/renderer/components/AboutModal.test.tsx
index 85a22423..9ae8b6e2 100644
--- a/src/__tests__/renderer/components/AboutModal.test.tsx
+++ b/src/__tests__/renderer/components/AboutModal.test.tsx
@@ -107,8 +107,9 @@ vi.mock('../../../renderer/components/AchievementCard', () => ({
),
}));
-// Add __APP_VERSION__ global
+// Add __APP_VERSION__ and __COMMIT_HASH__ globals
(globalThis as unknown as { __APP_VERSION__: string }).__APP_VERSION__ = '1.0.0';
+(globalThis as unknown as { __COMMIT_HASH__: string }).__COMMIT_HASH__ = '';
// Create test theme
const createTheme = (): Theme => ({
@@ -1110,4 +1111,5 @@ describe('AboutModal', () => {
expect(screen.getByText('$12,345,678.90')).toBeInTheDocument();
});
});
+
});
diff --git a/src/__tests__/renderer/components/UsageDashboard/SummaryCards.test.tsx b/src/__tests__/renderer/components/UsageDashboard/SummaryCards.test.tsx
index 4abae3da..a9c176f0 100644
--- a/src/__tests__/renderer/components/UsageDashboard/SummaryCards.test.tsx
+++ b/src/__tests__/renderer/components/UsageDashboard/SummaryCards.test.tsx
@@ -41,6 +41,8 @@ const mockData: StatsAggregation = {
sessionsByDay: [],
avgSessionDuration: 288000,
byAgentByDay: {},
+ bySessionByDay: {},
+
};
// Empty data for edge case testing
@@ -58,6 +60,8 @@ const emptyData: StatsAggregation = {
sessionsByDay: [],
avgSessionDuration: 0,
byAgentByDay: {},
+ bySessionByDay: {},
+
};
// Data with large numbers
@@ -78,6 +82,8 @@ const largeNumbersData: StatsAggregation = {
sessionsByDay: [],
avgSessionDuration: 7200000,
byAgentByDay: {},
+ bySessionByDay: {},
+
};
// Single agent data
@@ -97,6 +103,8 @@ const singleAgentData: StatsAggregation = {
sessionsByDay: [],
avgSessionDuration: 360000,
byAgentByDay: {},
+ bySessionByDay: {},
+
};
// Only auto queries
@@ -116,6 +124,8 @@ const onlyAutoData: StatsAggregation = {
sessionsByDay: [],
avgSessionDuration: 360000,
byAgentByDay: {},
+ bySessionByDay: {},
+
};
describe('SummaryCards', () => {
diff --git a/src/__tests__/renderer/components/UsageDashboard/chart-accessibility.test.tsx b/src/__tests__/renderer/components/UsageDashboard/chart-accessibility.test.tsx
index 388754cb..3973e8d8 100644
--- a/src/__tests__/renderer/components/UsageDashboard/chart-accessibility.test.tsx
+++ b/src/__tests__/renderer/components/UsageDashboard/chart-accessibility.test.tsx
@@ -69,6 +69,8 @@ const mockStatsData: StatsAggregation = {
],
avgSessionDuration: 288000,
byAgentByDay: {},
+ bySessionByDay: {},
+
};
describe('Chart Accessibility - AgentComparisonChart', () => {
@@ -409,8 +411,10 @@ describe('Chart Accessibility - General ARIA Patterns', () => {
sessionsByDay: [],
avgSessionDuration: 0,
byAgentByDay: {},
+ bySessionByDay: {},
};
+
render(
diff --git a/src/renderer/components/GroupChatHistoryPanel.tsx b/src/renderer/components/GroupChatHistoryPanel.tsx index 1085b56c..92cfd597 100644 --- a/src/renderer/components/GroupChatHistoryPanel.tsx +++ b/src/renderer/components/GroupChatHistoryPanel.tsx @@ -10,6 +10,7 @@ import React, { useState, useMemo, useRef, useEffect } from 'react'; import { Check } from 'lucide-react'; import type { Theme } from '../types'; import type { GroupChatHistoryEntry } from '../../shared/group-chat-types'; +import { stripMarkdown } from '../utils/textProcessing'; // Lookback period options for the activity graph type LookbackPeriod = { @@ -529,9 +530,9 @@ export function GroupChatHistoryPanel({ - {/* Summary - full text */} + {/* Summary - strip markdown for clean display */}
- {entry.summary} + {stripMarkdown(entry.summary)}
{/* Footer with cost */} diff --git a/src/renderer/components/UsageDashboard/AgentUsageChart.tsx b/src/renderer/components/UsageDashboard/AgentUsageChart.tsx index f15a0f43..f373184a 100644 --- a/src/renderer/components/UsageDashboard/AgentUsageChart.tsx +++ b/src/renderer/components/UsageDashboard/AgentUsageChart.tsx @@ -1,47 +1,52 @@ /** * AgentUsageChart * - * Line chart showing provider usage over time with one line per provider. - * Displays query counts and duration for each provider (claude-code, codex, opencode). + * Line chart showing Maestro agent (session) usage over time with one line per agent. + * Displays query counts and duration for each agent that was used during the time period. * * Features: - * - One line per provider - * - Dual Y-axes: queries (left) and time (right) - * - Provider-specific colors + * - One line per Maestro agent (named session from left panel) + * - Toggle between query count and time metrics + * - Session ID to name mapping when names are available * - Hover tooltips with exact values * - Responsive SVG rendering * - Theme-aware styling + * - Limits display to top 10 agents by query count */ import React, { useState, useMemo, useCallback } from 'react'; import { format, parseISO } from 'date-fns'; -import type { Theme } from '../../types'; +import type { Theme, Session } from '../../types'; import type { StatsTimeRange, StatsAggregation } from '../../hooks/useStats'; -import { - COLORBLIND_AGENT_PALETTE, - COLORBLIND_LINE_COLORS, -} from '../../constants/colorblindPalettes'; +import { COLORBLIND_AGENT_PALETTE } from '../../constants/colorblindPalettes'; -// Provider colors (matching AgentComparisonChart) -const PROVIDER_COLORS: Record