testDir = '/tmp/logviewer_test_' . uniqid(); mkdir($this->testDir, 0777, true); mkdir($this->testDir . '/app', 0777, true); mkdir($this->testDir . '/debug', 0777, true); // Create test log files file_put_contents( $this->testDir . '/app/error.log', "[2024-01-15 10:30:45] local.ERROR: Database connection failed\n" . "[2024-01-15 10:31:00] local.WARNING: Cache miss\n" . "[2024-01-15 10:31:30] local.INFO: User logged in\n" ); file_put_contents( $this->testDir . '/debug/test.log', "[2024-01-15 10:32:00] local.DEBUG: Debug message\n" . "[2024-01-15 10:32:30] local.ERROR: Test error\n" ); // Create config $this->config = new LogViewerConfig( storageLogsPath: $this->testDir, logDirectories: ['app', 'debug'], defaultLimit: 100 ); $this->logViewer = new LogViewer($this->config); }); afterEach(function () { // Cleanup test directory if (is_dir($this->testDir)) { array_map('unlink', glob($this->testDir . '/*/*.log')); rmdir($this->testDir . '/app'); rmdir($this->testDir . '/debug'); rmdir($this->testDir); } }); it('gets available logs', function () { $logs = $this->logViewer->getAvailableLogs(); expect($logs)->toBeArray(); expect($logs)->toHaveKey('app_error'); expect($logs)->toHaveKey('debug_test'); expect($logs['app_error']['name'])->toBeInstanceOf(LogName::class); expect($logs['app_error']['path'])->toBeInstanceOf(FilePath::class); expect($logs['app_error'])->toHaveKey('size'); expect($logs['app_error'])->toHaveKey('modified'); expect($logs['app_error']['readable'])->toBeTrue(); }); it('reads log with default limit', function () { $result = $this->logViewer->readLog('app_error'); expect($result)->toBeInstanceOf(LogReadResult::class); expect($result->entries)->toHaveCount(3); expect($result->logName->value)->toBe('app_error'); }); it('reads log with custom limit', function () { $result = $this->logViewer->readLog('app_error', limit: 2); expect($result->entries)->toHaveCount(2); expect($result->limit)->toBe(2); }); it('reads log with level filter', function () { $result = $this->logViewer->readLog('app_error', level: LogLevel::ERROR); expect($result->entries)->toHaveCount(1); expect($result->entries[0]->level)->toBe(LogLevel::ERROR); }); it('reads log with string level filter', function () { $result = $this->logViewer->readLog('app_error', level: 'ERROR'); expect($result->entries)->toHaveCount(1); expect($result->entries[0]->level)->toBe(LogLevel::ERROR); }); it('reads log with search filter', function () { $result = $this->logViewer->readLog('app_error', search: 'Database'); expect($result->entries)->toHaveCount(1); expect($result->entries[0]->message)->toContain('Database'); }); it('reads log with combined filters', function () { $result = $this->logViewer->readLog( 'app_error', level: LogLevel::ERROR, search: 'Database' ); expect($result->entries)->toHaveCount(1); expect($result->entries[0]->level)->toBe(LogLevel::ERROR); expect($result->entries[0]->message)->toContain('Database'); }); it('accepts LogName value object', function () { $logName = LogName::fromString('app_error'); $result = $this->logViewer->readLog($logName); expect($result->entries)->toHaveCount(3); }); it('throws on non-existent log', function () { $this->logViewer->readLog('nonexistent_log'); })->throws(\InvalidArgumentException::class, "Log 'nonexistent_log' not found"); it('tails log with default lines', function () { $result = $this->logViewer->tailLog('app_error'); expect($result)->toBeInstanceOf(LogReadResult::class); expect($result->entries)->toHaveCount(3); // Entries should be in reverse order (most recent first) expect($result->entries[0]->message)->toContain('User logged in'); }); it('tails log with custom line count', function () { $result = $this->logViewer->tailLog('app_error', lines: 2); expect($result->entries)->toHaveCount(2); expect($result->limit)->toBe(2); }); it('searches across multiple logs', function () { $result = $this->logViewer->searchLogs('error'); expect($result)->toBeInstanceOf(LogSearchResult::class); expect($result->totalMatches)->toBeGreaterThan(0); expect($result->filesSearched)->toBeGreaterThan(0); }); it('searches specific logs', function () { $result = $this->logViewer->searchLogs( 'error', logNames: ['app_error'] ); expect($result->filesSearched)->toBe(1); }); it('searches with level filter', function () { $result = $this->logViewer->searchLogs( 'error', level: LogLevel::ERROR ); $allEntries = $result->getAllEntries(); foreach ($allEntries as $entry) { expect($entry->level)->toBe(LogLevel::ERROR); } }); it('streams log entries in batches', function () { $batchSize = 2; $batches = []; foreach ($this->logViewer->streamLog('app_error', batchSize: $batchSize) as $batch) { $batches[] = $batch; } expect($batches)->toHaveCount(2); // 3 entries / 2 per batch = 2 batches expect($batches[0]['entries_in_batch'])->toBe(2); expect($batches[1]['entries_in_batch'])->toBe(1); expect($batches[1]['is_final'])->toBeTrue(); }); it('streams log with level filter', function () { $batches = []; foreach ($this->logViewer->streamLog('app_error', level: LogLevel::ERROR) as $batch) { $batches[] = $batch; } $totalEntries = array_sum(array_column($batches, 'entries_in_batch')); expect($totalEntries)->toBe(1); }); it('streams log with search filter', function () { $batches = []; foreach ($this->logViewer->streamLog('app_error', search: 'Database') as $batch) { $batches[] = $batch; } $totalEntries = array_sum(array_column($batches, 'entries_in_batch')); expect($totalEntries)->toBe(1); }); it('handles empty log file', function () { // Create empty log file file_put_contents($this->testDir . '/app/empty.log', ''); $result = $this->logViewer->readLog('app_empty'); expect($result->isEmpty())->toBeTrue(); expect($result->entries)->toBe([]); }); it('handles log with unstructured lines', function () { // Create log with mixed structured and unstructured lines file_put_contents( $this->testDir . '/app/mixed.log', "[2024-01-15 10:30:45] local.ERROR: Structured error\n" . "Unstructured log line\n" . "Another unstructured line\n" ); $result = $this->logViewer->readLog('app_mixed'); expect($result->entries)->toHaveCount(3); expect($result->entries[0]->parsed)->toBeTrue(); expect($result->entries[1]->parsed)->toBeFalse(); expect($result->entries[2]->parsed)->toBeFalse(); }); it('handles log with unknown level', function () { file_put_contents( $this->testDir . '/app/unknown.log', "[2024-01-15 10:30:45] local.UNKNOWN: Unknown level\n" ); $result = $this->logViewer->readLog('app_unknown'); expect($result->entries)->toHaveCount(1); // Should fallback to INFO for unknown levels expect($result->entries[0]->level)->toBe(LogLevel::INFO); }); it('skips unreadable log files', function () { // Create unreadable file (can't actually test this without root permissions) // Just verify that getAvailableLogs filters correctly $logs = $this->logViewer->getAvailableLogs(); foreach ($logs as $log) { expect($log['readable'])->toBeTrue(); } }); });