diff --git a/PolyPilot.Tests/PolyPilot.Tests.csproj b/PolyPilot.Tests/PolyPilot.Tests.csproj index bf9e6c4984..ef28dde2e6 100644 --- a/PolyPilot.Tests/PolyPilot.Tests.csproj +++ b/PolyPilot.Tests/PolyPilot.Tests.csproj @@ -44,6 +44,7 @@ + diff --git a/PolyPilot.Tests/SessionAnalyzerTests.cs b/PolyPilot.Tests/SessionAnalyzerTests.cs new file mode 100644 index 0000000000..85a2486df2 --- /dev/null +++ b/PolyPilot.Tests/SessionAnalyzerTests.cs @@ -0,0 +1,295 @@ +using PolyPilot.Services; + +namespace PolyPilot.Tests; + +[Collection("BaseDir")] +public class SessionAnalyzerTests +{ + [Fact] + public void CollectDiagnostics_IncludesServerHealth() + { + var tempDir = Path.Combine(Path.GetTempPath(), $"analyzer-test-{Guid.NewGuid()}"); + Directory.CreateDirectory(tempDir); + try + { + SessionAnalyzerService.SetBaseDirForTesting(tempDir); + + File.WriteAllText( + Path.Combine(tempDir, "event-diagnostics.log"), + "[SEND] 'TestSession' IsProcessing=true\n[COMPLETE] 'TestSession' done\n"); + + var copilotService = CreateService(); + var serverManager = new TestServerManager { IsRunning = true, Pid = 12345, Port = 4321 }; + var analyzer = new SessionAnalyzerService(copilotService, serverManager); + + var diagnostics = analyzer.CollectDiagnostics(); + + Assert.Contains("Event Diagnostics", diagnostics); + Assert.Contains("[SEND]", diagnostics); + Assert.Contains("[COMPLETE]", diagnostics); + Assert.Contains("Server running: True", diagnostics); + Assert.Contains("12345", diagnostics); + } + finally + { + SessionAnalyzerService.SetBaseDirForTesting(TestSetup.TestBaseDir); + Directory.Delete(tempDir, recursive: true); + } + } + + [Fact] + public void CollectDiagnostics_IncludesCrashLog_WhenPresent() + { + var tempDir = Path.Combine(Path.GetTempPath(), $"analyzer-test-{Guid.NewGuid()}"); + Directory.CreateDirectory(tempDir); + try + { + SessionAnalyzerService.SetBaseDirForTesting(tempDir); + + File.WriteAllText( + Path.Combine(tempDir, "crash.log"), + "=== 2026-04-18 ===\nSystem.Exception: test crash\n"); + + var copilotService = CreateService(); + var serverManager = new TestServerManager(); + var analyzer = new SessionAnalyzerService(copilotService, serverManager); + + var diagnostics = analyzer.CollectDiagnostics(); + + Assert.Contains("Crash Log", diagnostics); + Assert.Contains("test crash", diagnostics); + } + finally + { + SessionAnalyzerService.SetBaseDirForTesting(TestSetup.TestBaseDir); + Directory.Delete(tempDir, recursive: true); + } + } + + [Fact] + public void CollectDiagnostics_HandlesEmptyLogs() + { + var tempDir = Path.Combine(Path.GetTempPath(), $"analyzer-test-{Guid.NewGuid()}"); + Directory.CreateDirectory(tempDir); + try + { + SessionAnalyzerService.SetBaseDirForTesting(tempDir); + + var copilotService = CreateService(); + var serverManager = new TestServerManager(); + var analyzer = new SessionAnalyzerService(copilotService, serverManager); + + var diagnostics = analyzer.CollectDiagnostics(); + + Assert.Contains("Active Session States", diagnostics); + Assert.Contains("Server Health", diagnostics); + } + finally + { + SessionAnalyzerService.SetBaseDirForTesting(TestSetup.TestBaseDir); + Directory.Delete(tempDir, recursive: true); + } + } + + [Fact] + public void BuildAnalysisPrompt_ContainsDiagnosticData() + { + var diagnostics = "## Test Data\nSome diagnostic info here"; + var prompt = SessionAnalyzerService.BuildAnalysisPrompt(diagnostics); + + Assert.Contains("PolyPilot Session Analyzer", prompt); + Assert.Contains("Stuck sessions", prompt); + Assert.Contains("Watchdog kills", prompt); + Assert.Contains("Test Data", prompt); + Assert.Contains("Some diagnostic info here", prompt); + } + + [Fact] + public void BuildAnalysisPrompt_InstructsSingleBranchPrStrategy() + { + var prompt = SessionAnalyzerService.BuildAnalysisPrompt("data"); + + // Must instruct reuse of a single branch and PR + Assert.Contains("fix/session-analyzer-findings", prompt); + Assert.Contains("Always reuse the SAME branch", prompt); + Assert.Contains("Never create a new branch per finding", prompt); + } + + [Fact] + public void Constants_HaveReasonableDefaults() + { + Assert.Equal(10, SessionAnalyzerService.DefaultAnalysisIntervalMinutes); + Assert.Equal(1, SessionAnalyzerService.MinAnalysisIntervalMinutes); + Assert.Equal(1440, SessionAnalyzerService.MaxAnalysisIntervalMinutes); + Assert.Equal(200, SessionAnalyzerService.DiagnosticLogTailLines); + Assert.Equal(50, SessionAnalyzerService.CrashLogTailLines); + Assert.Equal("PolyPilot Monitor", SessionAnalyzerService.AnalyzerSessionName); + Assert.Equal(10 * 1024 * 1024, SessionAnalyzerService.MaxLogFileSizeBytes); + } + + [Fact] + public void IsRunning_FalseBeforeStart() + { + var copilotService = CreateService(); + var serverManager = new TestServerManager(); + var analyzer = new SessionAnalyzerService(copilotService, serverManager); + + Assert.False(analyzer.IsRunning); + Assert.Null(analyzer.LastAnalysisAt); + Assert.Equal(0, analyzer.AnalysisCount); + } + + [Fact] + public void Dispose_StopsAnalyzer() + { + var copilotService = CreateService(); + var serverManager = new TestServerManager(); + var analyzer = new SessionAnalyzerService(copilotService, serverManager); + + analyzer.Dispose(); + analyzer.Dispose(); // double dispose is safe + Assert.False(analyzer.IsRunning); + } + + [Fact] + public async Task DisposeAsync_StopsAnalyzer() + { + var copilotService = CreateService(); + var serverManager = new TestServerManager(); + var analyzer = new SessionAnalyzerService(copilotService, serverManager); + + await analyzer.DisposeAsync(); + await analyzer.DisposeAsync(); // double dispose is safe + Assert.False(analyzer.IsRunning); + } + + [Fact] + public async Task RunSingleAnalysis_ReturnsNull_WhenNoSessionCreated() + { + var copilotService = CreateService(); + var serverManager = new TestServerManager(); + var analyzer = new SessionAnalyzerService(copilotService, serverManager); + + // _analyzerSessionName is null — no session was created + var result = await analyzer.RunSingleAnalysisAsync(); + Assert.Null(result); + Assert.Equal(0, analyzer.AnalysisCount); + } + + [Fact] + public void TailFile_CapsLargeFiles() + { + var tempFile = Path.GetTempFileName(); + try + { + // Write a small file — TailFile should return last N lines + var lines = Enumerable.Range(1, 500).Select(i => $"line {i}").ToArray(); + File.WriteAllLines(tempFile, lines); + + var result = SessionAnalyzerService.TailFile(tempFile, 10); + Assert.Equal(10, result.Length); + Assert.Equal("line 491", result[0]); + Assert.Equal("line 500", result[9]); + } + finally + { + File.Delete(tempFile); + } + } + + [Fact] + public void TailFile_HandlesSmallFile() + { + var tempFile = Path.GetTempFileName(); + try + { + File.WriteAllLines(tempFile, new[] { "a", "b", "c" }); + var result = SessionAnalyzerService.TailFile(tempFile, 10); + Assert.Equal(3, result.Length); + } + finally + { + File.Delete(tempFile); + } + } + + [Fact] + public void TailFile_HandlesNonexistentFile() + { + var result = SessionAnalyzerService.TailFile("/nonexistent/path", 10); + Assert.Empty(result); + } + + [Fact] + public void SessionAnalyzerIntervalMinutes_ClampsToMinimum() + { + var settings = new PolyPilot.Models.ConnectionSettings(); + + settings.SessionAnalyzerIntervalMinutes = 0; + Assert.Equal(1, settings.SessionAnalyzerIntervalMinutes); + + settings.SessionAnalyzerIntervalMinutes = -5; + Assert.Equal(1, settings.SessionAnalyzerIntervalMinutes); + + settings.SessionAnalyzerIntervalMinutes = 30; + Assert.Equal(30, settings.SessionAnalyzerIntervalMinutes); + } + + [Fact] + public void SessionAnalyzerIntervalMinutes_ClampsToMaximum() + { + var settings = new PolyPilot.Models.ConnectionSettings(); + + settings.SessionAnalyzerIntervalMinutes = 2000; + Assert.Equal(1440, settings.SessionAnalyzerIntervalMinutes); + + settings.SessionAnalyzerIntervalMinutes = int.MaxValue; + Assert.Equal(1440, settings.SessionAnalyzerIntervalMinutes); + } + + [Fact] + public void BuildAnalysisPrompt_UsesAutopilotMode() + { + // The analyzer runs in autopilot so it can create/update PRs + var prompt = SessionAnalyzerService.BuildAnalysisPrompt("data"); + + Assert.Contains("Write the fix, run tests, commit", prompt); + Assert.Contains("open one with a clear title", prompt); + } + + private static string GetTempDir() => Path.GetTempPath(); + + private static CopilotService CreateService() + { + var services = new Microsoft.Extensions.DependencyInjection.ServiceCollection(); + var serviceProvider = Microsoft.Extensions.DependencyInjection.ServiceCollectionContainerBuilderExtensions + .BuildServiceProvider(services); + return new CopilotService( + new StubChatDatabase(), + new StubServerManager(), + new StubWsBridgeClient(), + new RepoManager(), + serviceProvider, + new StubDemoService()); + } + + private class TestServerManager : IServerManager + { + public bool IsRunning { get; set; } + public int? Pid { get; set; } + public int Port { get; set; } = 4321; + public string? Error { get; set; } + + bool IServerManager.IsServerRunning => IsRunning; + int? IServerManager.ServerPid => Pid; + int IServerManager.ServerPort => Port; + string? IServerManager.LastError => Error; + + public event Action? OnStatusChanged; + + public bool CheckServerRunning(string host = "127.0.0.1", int? port = null) => IsRunning; + public Task StartServerAsync(int port, string? githubToken = null) => Task.FromResult(true); + public void StopServer() { } + public bool DetectExistingServer() => IsRunning; + } +} diff --git a/PolyPilot.Tests/TestSetup.cs b/PolyPilot.Tests/TestSetup.cs index ba4a065433..979a9f4026 100644 --- a/PolyPilot.Tests/TestSetup.cs +++ b/PolyPilot.Tests/TestSetup.cs @@ -36,5 +36,6 @@ internal static void Initialize() FiestaService.SetStateFilePathForTesting(Path.Combine(TestBaseDir, "fiesta.json")); ConnectionSettings.SetSettingsFilePathForTesting(Path.Combine(TestBaseDir, "settings.json")); ScheduledTaskService.SetTasksFilePathForTesting(Path.Combine(TestBaseDir, "scheduled-tasks.json")); + SessionAnalyzerService.SetBaseDirForTesting(TestBaseDir); } } diff --git a/PolyPilot/Components/Pages/Settings.razor b/PolyPilot/Components/Pages/Settings.razor index 41bc363e9d..f1cb433203 100644 --- a/PolyPilot/Components/Pages/Settings.razor +++ b/PolyPilot/Components/Pages/Settings.razor @@ -536,6 +536,30 @@ } + @if (PlatformHelper.IsDesktop) + { +
+
+ +

Background monitor that analyzes sessions for reliability issues and can auto-fix bugs via PR

+
+ @if (settings.EnableSessionAnalyzer) + { +
+ +
+ } +
+ } +