| 1 | using CascadeIDE.Features.Agent.Environment; |
| 2 | using CascadeIDE.Models; |
| 3 | using CascadeIDE.Services; |
| 4 | using Xunit; |
| 5 | |
| 6 | namespace CascadeIDE.Tests; |
| 7 | |
| 8 | [Trait("Category", "AgentEnvironment")] |
| 9 | public sealed class AgentRoslynDiagnoseFilesDiagnosticsTests |
| 10 | { |
| 11 | [Fact] |
| 12 | public async Task RunAsync_skipped_when_language_or_resolver_missing() |
| 13 | { |
| 14 | var noLang = new AgentRoslynDiagnoseFilesDiagnostics(null, () => []); |
| 15 | var outcome = await noLang.RunAsync(); |
| 16 | Assert.Contains("no language service", outcome.Detail, StringComparison.Ordinal); |
| 17 | |
| 18 | var noResolver = new AgentRoslynDiagnoseFilesDiagnostics(new CSharpLanguageService(), null); |
| 19 | outcome = await noResolver.RunAsync(); |
| 20 | Assert.Contains("no language service", outcome.Detail, StringComparison.Ordinal); |
| 21 | } |
| 22 | |
| 23 | [Fact] |
| 24 | public async Task RunAsync_open_tabs_detects_syntax_error() |
| 25 | { |
| 26 | var path = Path.Combine(Path.GetTempPath(), "cide-diagnose-open-" + Guid.NewGuid().ToString("N") + ".cs"); |
| 27 | try |
| 28 | { |
| 29 | var broken = "class Broken { void M( { }"; |
| 30 | var diagnose = new AgentRoslynDiagnoseFilesDiagnostics( |
| 31 | new CSharpLanguageService(), |
| 32 | () => [(path, broken)], |
| 33 | new AgentEnvironmentLadderSettings { DiagnoseFilesCsScope = AgentDiagnoseFilesCsScopeParser.OpenTabsOnly }); |
| 34 | |
| 35 | var outcome = await diagnose.RunAsync(); |
| 36 | |
| 37 | Assert.False(outcome.Green); |
| 38 | Assert.True(outcome.ErrorCount > 0); |
| 39 | Assert.Contains("1 file", outcome.Detail, StringComparison.Ordinal); |
| 40 | } |
| 41 | finally |
| 42 | { |
| 43 | try { File.Delete(path); } catch { /* best-effort */ } |
| 44 | } |
| 45 | } |
| 46 | |
| 47 | [Fact] |
| 48 | public async Task RunAsync_git_dirty_loads_cs_from_disk_when_not_open() |
| 49 | { |
| 50 | var dir = Directory.CreateTempSubdirectory("cide-diagnose-git-"); |
| 51 | try |
| 52 | { |
| 53 | var onlyGit = Path.Combine(dir.FullName, "OnlyGit.cs"); |
| 54 | await File.WriteAllTextAsync(onlyGit, "namespace T; public class Ok { }"); |
| 55 | |
| 56 | var git = new GitNameOnlyRunner |
| 57 | { |
| 58 | UnstagedOutput = "OnlyGit.cs\nreadme.txt", |
| 59 | }; |
| 60 | |
| 61 | var diagnose = new AgentRoslynDiagnoseFilesDiagnostics( |
| 62 | new CSharpLanguageService(), |
| 63 | () => [], |
| 64 | new AgentEnvironmentLadderSettings { DiagnoseFilesCsScope = AgentDiagnoseFilesCsScopeParser.OpenTabsAndGitDirtyCs }, |
| 65 | git, |
| 66 | () => dir.FullName); |
| 67 | |
| 68 | var outcome = await diagnose.RunAsync(); |
| 69 | |
| 70 | Assert.True(outcome.Green); |
| 71 | Assert.Contains("1 file", outcome.Detail, StringComparison.Ordinal); |
| 72 | Assert.Equal(2, git.Invocations.Count); |
| 73 | } |
| 74 | finally |
| 75 | { |
| 76 | try { dir.Delete(recursive: true); } catch { /* best-effort */ } |
| 77 | } |
| 78 | } |
| 79 | |
| 80 | [Fact] |
| 81 | public async Task RunAsync_open_tab_buffer_overrides_disk_for_same_path() |
| 82 | { |
| 83 | var dir = Directory.CreateTempSubdirectory("cide-diagnose-priority-"); |
| 84 | try |
| 85 | { |
| 86 | var cs = Path.Combine(dir.FullName, "Same.cs"); |
| 87 | await File.WriteAllTextAsync(cs, "namespace T; public class Ok { }"); |
| 88 | |
| 89 | var git = new GitNameOnlyRunner { UnstagedOutput = "Same.cs" }; |
| 90 | var diagnose = new AgentRoslynDiagnoseFilesDiagnostics( |
| 91 | new CSharpLanguageService(), |
| 92 | () => [(cs, "class Broken { void M( { }")], |
| 93 | new AgentEnvironmentLadderSettings { DiagnoseFilesCsScope = AgentDiagnoseFilesCsScopeParser.OpenTabsAndGitDirtyCs }, |
| 94 | git, |
| 95 | () => dir.FullName); |
| 96 | |
| 97 | var outcome = await diagnose.RunAsync(); |
| 98 | |
| 99 | Assert.False(outcome.Green); |
| 100 | Assert.True(outcome.ErrorCount > 0); |
| 101 | } |
| 102 | finally |
| 103 | { |
| 104 | try { dir.Delete(recursive: true); } catch { /* best-effort */ } |
| 105 | } |
| 106 | } |
| 107 | |
| 108 | [Fact] |
| 109 | public async Task RunAsync_git_dirty_respects_max_files_cap() |
| 110 | { |
| 111 | var dir = Directory.CreateTempSubdirectory("cide-diagnose-cap-"); |
| 112 | try |
| 113 | { |
| 114 | for (var i = 0; i < 5; i++) |
| 115 | await File.WriteAllTextAsync(Path.Combine(dir.FullName, $"F{i}.cs"), $"namespace T{i}; public class C{i} {{}}"); |
| 116 | |
| 117 | var git = new GitNameOnlyRunner |
| 118 | { |
| 119 | UnstagedOutput = "F0.cs\nF1.cs\nF2.cs\nF3.cs\nF4.cs", |
| 120 | }; |
| 121 | |
| 122 | var diagnose = new AgentRoslynDiagnoseFilesDiagnostics( |
| 123 | new CSharpLanguageService(), |
| 124 | () => [], |
| 125 | new AgentEnvironmentLadderSettings |
| 126 | { |
| 127 | DiagnoseFilesCsScope = AgentDiagnoseFilesCsScopeParser.OpenTabsAndGitDirtyCs, |
| 128 | DiagnoseFilesGitDirtyMaxFiles = 2, |
| 129 | }, |
| 130 | git, |
| 131 | () => dir.FullName); |
| 132 | |
| 133 | var outcome = await diagnose.RunAsync(); |
| 134 | |
| 135 | Assert.True(outcome.Green); |
| 136 | Assert.Contains("2 file", outcome.Detail, StringComparison.Ordinal); |
| 137 | } |
| 138 | finally |
| 139 | { |
| 140 | try { dir.Delete(recursive: true); } catch { /* best-effort */ } |
| 141 | } |
| 142 | } |
| 143 | |
| 144 | [Fact] |
| 145 | public async Task RunAsync_open_tabs_only_does_not_call_git() |
| 146 | { |
| 147 | var git = new GitNameOnlyRunner { UnstagedOutput = "Ghost.cs" }; |
| 148 | var diagnose = new AgentRoslynDiagnoseFilesDiagnostics( |
| 149 | new CSharpLanguageService(), |
| 150 | () => [], |
| 151 | new AgentEnvironmentLadderSettings { DiagnoseFilesCsScope = AgentDiagnoseFilesCsScopeParser.OpenTabsOnly }, |
| 152 | git, |
| 153 | () => @"C:\any"); |
| 154 | |
| 155 | var outcome = await diagnose.RunAsync(); |
| 156 | |
| 157 | Assert.Empty(git.Invocations); |
| 158 | Assert.Contains("no .cs inputs", outcome.Detail, StringComparison.Ordinal); |
| 159 | } |
| 160 | |
| 161 | [Fact] |
| 162 | public async Task RunAsync_warmup_loads_cs_from_disk_when_not_open() |
| 163 | { |
| 164 | var dir = Directory.CreateTempSubdirectory("cide-diagnose-warmup-"); |
| 165 | try |
| 166 | { |
| 167 | var onlyWarmup = Path.Combine(dir.FullName, "WarmOnly.cs"); |
| 168 | await File.WriteAllTextAsync(onlyWarmup, "namespace T; public class Warm { }"); |
| 169 | |
| 170 | var diagnose = new AgentRoslynDiagnoseFilesDiagnostics( |
| 171 | new CSharpLanguageService(), |
| 172 | () => [], |
| 173 | new AgentEnvironmentLadderSettings { DiagnoseFilesIncludeWarmupCs = true }, |
| 174 | getWarmupCsFilePaths: () => [onlyWarmup]); |
| 175 | |
| 176 | var outcome = await diagnose.RunAsync(); |
| 177 | |
| 178 | Assert.True(outcome.Green); |
| 179 | Assert.Contains("1 file", outcome.Detail, StringComparison.Ordinal); |
| 180 | } |
| 181 | finally |
| 182 | { |
| 183 | try { dir.Delete(recursive: true); } catch { /* best-effort */ } |
| 184 | } |
| 185 | } |
| 186 | |
| 187 | [Fact] |
| 188 | public async Task RunAsync_warmup_respects_max_files_cap() |
| 189 | { |
| 190 | var dir = Directory.CreateTempSubdirectory("cide-diagnose-warmup-cap-"); |
| 191 | try |
| 192 | { |
| 193 | var paths = new List<string>(); |
| 194 | for (var i = 0; i < 4; i++) |
| 195 | { |
| 196 | var p = Path.Combine(dir.FullName, $"W{i}.cs"); |
| 197 | await File.WriteAllTextAsync(p, $"namespace T{i}; public class C{i} {{}}"); |
| 198 | paths.Add(p); |
| 199 | } |
| 200 | |
| 201 | var diagnose = new AgentRoslynDiagnoseFilesDiagnostics( |
| 202 | new CSharpLanguageService(), |
| 203 | () => [], |
| 204 | new AgentEnvironmentLadderSettings |
| 205 | { |
| 206 | DiagnoseFilesIncludeWarmupCs = true, |
| 207 | DiagnoseFilesWarmupMaxFiles = 2, |
| 208 | }, |
| 209 | getWarmupCsFilePaths: () => paths); |
| 210 | |
| 211 | var outcome = await diagnose.RunAsync(); |
| 212 | |
| 213 | Assert.True(outcome.Green); |
| 214 | Assert.Contains("2 file", outcome.Detail, StringComparison.Ordinal); |
| 215 | } |
| 216 | finally |
| 217 | { |
| 218 | try { dir.Delete(recursive: true); } catch { /* best-effort */ } |
| 219 | } |
| 220 | } |
| 221 | |
| 222 | private sealed class GitNameOnlyRunner : IGitCommandRunner |
| 223 | { |
| 224 | public string? UnstagedOutput { get; set; } |
| 225 | |
| 226 | public string? StagedOutput { get; set; } |
| 227 | |
| 228 | public List<IReadOnlyList<string>> Invocations { get; } = []; |
| 229 | |
| 230 | public Task<(bool Success, int ExitCode, string Output)> RunAsync( |
| 231 | IReadOnlyList<string> args, |
| 232 | string workingDirectory, |
| 233 | CancellationToken cancellationToken = default) |
| 234 | { |
| 235 | Invocations.Add(args); |
| 236 | var isCached = args.Any(a => string.Equals(a, "--cached", StringComparison.Ordinal)); |
| 237 | var isNameOnly = args.Any(a => string.Equals(a, "--name-only", StringComparison.Ordinal)); |
| 238 | if (isNameOnly && isCached) |
| 239 | return Task.FromResult((true, 0, StagedOutput ?? "")); |
| 240 | if (isNameOnly) |
| 241 | return Task.FromResult((true, 0, UnstagedOutput ?? "")); |
| 242 | return Task.FromResult((true, 0, "")); |
| 243 | } |
| 244 | } |
| 245 | } |
| 246 | |