using System; using System.Collections.Generic; using System.Diagnostics; using System.IO; using System.Linq; using System.Text; using System.Text.Json; using System.Text.RegularExpressions; using System.Threading; using System.Threading.Tasks; using System.Windows; namespace AxCopilot.Services.Agent; public class TestLoopTool : IAgentTool { private class FailureDetail { public string TestName { get; set; } = ""; public string FilePath { get; set; } = ""; public int Line { get; set; } public string Message { get; set; } = ""; } public string Name => "test_loop"; public string Description => "코드 변경에 대한 테스트를 자동으로 생성하고 실행합니다.\n- action=\"generate\": 변경된 파일에 대한 테스트 코드 생성 제안\n- action=\"run\": 프로젝트의 테스트를 실행하고 결과 반환\n- action=\"analyze\": 테스트 결과를 분석하여 수정 방향 제시\n- action=\"auto_fix\": 테스트 실행 → 실패 파싱 → 구조화된 수정 지침 반환 (반복 수정용)\n테스트 프레임워크를 자동 감지합니다 (xUnit, NUnit, MSTest, pytest, Jest 등)."; public ToolParameterSchema Parameters => new ToolParameterSchema { Properties = new Dictionary { ["action"] = new ToolProperty { Type = "string", Description = "수행할 작업: generate | run | analyze | auto_fix", Enum = new List { "generate", "run", "analyze", "auto_fix" } }, ["file_path"] = new ToolProperty { Type = "string", Description = "대상 소스 파일 경로 (generate 시 필요)" }, ["test_output"] = new ToolProperty { Type = "string", Description = "분석할 테스트 출력 (analyze 시 필요)" } }, Required = new List { "action" } }; public async Task ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default(CancellationToken)) { JsonElement a; string action = (args.TryGetProperty("action", out a) ? (a.GetString() ?? "") : ""); if (1 == 0) { } ToolResult result = action switch { "generate" => GenerateTestSuggestion(args, context), "run" => await RunTestsAsync(context, ct), "analyze" => AnalyzeTestOutput(args), "auto_fix" => await AutoFixAsync(context, ct), _ => ToolResult.Fail("action은 generate, run, analyze, auto_fix 중 하나여야 합니다."), }; if (1 == 0) { } return result; } private static ToolResult GenerateTestSuggestion(JsonElement args, AgentContext context) { JsonElement value; string text = (args.TryGetProperty("file_path", out value) ? (value.GetString() ?? "") : ""); if (string.IsNullOrEmpty(text)) { return ToolResult.Fail("file_path가 필요합니다."); } if (!Path.IsPathRooted(text) && !string.IsNullOrEmpty(context.WorkFolder)) { text = Path.Combine(context.WorkFolder, text); } if (!File.Exists(text)) { return ToolResult.Fail("파일 없음: " + text); } string text2 = Path.GetExtension(text).ToLowerInvariant(); if (1 == 0) { } (string, string, string) tuple; switch (text2) { case ".cs": tuple = ("xUnit/NUnit/MSTest", ".cs", "ClassNameTests.cs"); break; case ".py": tuple = ("pytest", ".py", "test_module.py"); break; case ".ts": case ".tsx": tuple = ("Jest/Vitest", ".test.ts", "Component.test.ts"); break; case ".js": case ".jsx": tuple = ("Jest", ".test.js", "module.test.js"); break; case ".java": tuple = ("JUnit", ".java", "ClassTest.java"); break; case ".go": tuple = ("go test", "_test.go", "module_test.go"); break; default: tuple = ("unknown", text2, "test" + text2); break; } if (1 == 0) { } (string, string, string) tuple2 = tuple; string item = tuple2.Item1; string item2 = tuple2.Item2; string item3 = tuple2.Item3; string text3 = File.ReadAllText(text); int value2 = text3.Split('\n').Length; return ToolResult.Ok($"테스트 생성 제안:\n 대상 파일: {Path.GetFileName(text)} ({value2}줄)\n 감지된 프레임워크: {item}\n 테스트 파일 명명: {item3}\n 테스트 파일 확장자: {item2}\n\nfile_write 도구로 테스트 파일을 생성한 후, test_loop action=\"run\"으로 실행하세요."); } private static async Task RunTestsAsync(AgentContext context, CancellationToken ct) { if (string.IsNullOrEmpty(context.WorkFolder)) { return ToolResult.Fail("작업 폴더가 설정되어 있지 않습니다."); } var (cmd, cmdArgs) = DetectTestCommand(context.WorkFolder); if (cmd == null) { return ToolResult.Fail("테스트 프레임워크를 감지할 수 없습니다. 지원: .NET (dotnet test), Python (pytest), Node.js (npm test)"); } try { ProcessStartInfo psi = new ProcessStartInfo { FileName = cmd, Arguments = cmdArgs, WorkingDirectory = context.WorkFolder, UseShellExecute = false, RedirectStandardOutput = true, RedirectStandardError = true, CreateNoWindow = true }; using Process proc = Process.Start(psi); if (proc == null) { return ToolResult.Fail("테스트 프로세스 시작 실패"); } using CancellationTokenSource cts = CancellationTokenSource.CreateLinkedTokenSource(ct); cts.CancelAfter(TimeSpan.FromSeconds(120.0)); string stdout = await proc.StandardOutput.ReadToEndAsync(cts.Token); string stderr = await proc.StandardError.ReadToEndAsync(cts.Token); await proc.WaitForExitAsync(cts.Token); StringBuilder sb = new StringBuilder(); StringBuilder stringBuilder = sb; StringBuilder stringBuilder2 = stringBuilder; StringBuilder.AppendInterpolatedStringHandler handler = new StringBuilder.AppendInterpolatedStringHandler(24, 1, stringBuilder); handler.AppendLiteral("테스트 실행 결과 (exit code: "); handler.AppendFormatted(proc.ExitCode); handler.AppendLiteral("):"); stringBuilder2.AppendLine(ref handler); stringBuilder = sb; StringBuilder stringBuilder3 = stringBuilder; handler = new StringBuilder.AppendInterpolatedStringHandler(5, 2, stringBuilder); handler.AppendLiteral("명령: "); handler.AppendFormatted(cmd); handler.AppendLiteral(" "); handler.AppendFormatted(cmdArgs); stringBuilder3.AppendLine(ref handler); sb.AppendLine(); if (!string.IsNullOrWhiteSpace(stdout)) { sb.AppendLine(stdout); } if (!string.IsNullOrWhiteSpace(stderr)) { stringBuilder = sb; StringBuilder stringBuilder4 = stringBuilder; handler = new StringBuilder.AppendInterpolatedStringHandler(9, 1, stringBuilder); handler.AppendLiteral("[STDERR]\n"); handler.AppendFormatted(stderr); stringBuilder4.AppendLine(ref handler); } return (proc.ExitCode == 0) ? ToolResult.Ok(sb.ToString()) : ToolResult.Ok($"테스트 실패 (exit code {proc.ExitCode}):\n{sb}"); } catch (Exception ex) { return ToolResult.Fail("테스트 실행 오류: " + ex.Message); } } private static ToolResult AnalyzeTestOutput(JsonElement args) { JsonElement value; string text = (args.TryGetProperty("test_output", out value) ? (value.GetString() ?? "") : ""); if (string.IsNullOrEmpty(text)) { return ToolResult.Fail("test_output이 필요합니다."); } StringBuilder stringBuilder = new StringBuilder(); stringBuilder.AppendLine("테스트 결과 분석:"); string[] source = text.Split('\n'); int num = source.Count((string l) => l.Contains("FAIL", StringComparison.OrdinalIgnoreCase) || l.Contains("FAILED")); int value2 = source.Count((string l) => l.Contains("PASS", StringComparison.OrdinalIgnoreCase) || l.Contains("PASSED")); List list = source.Where((string l) => l.Contains("Error", StringComparison.OrdinalIgnoreCase) || l.Contains("Exception")).Take(10).ToList(); StringBuilder stringBuilder2 = stringBuilder; StringBuilder stringBuilder3 = stringBuilder2; StringBuilder.AppendInterpolatedStringHandler handler = new StringBuilder.AppendInterpolatedStringHandler(14, 2, stringBuilder2); handler.AppendLiteral(" 통과: "); handler.AppendFormatted(value2); handler.AppendLiteral("개, 실패: "); handler.AppendFormatted(num); handler.AppendLiteral("개"); stringBuilder3.AppendLine(ref handler); if (list.Count > 0) { stringBuilder.AppendLine("\n주요 오류:"); foreach (string item in list) { stringBuilder2 = stringBuilder; StringBuilder stringBuilder4 = stringBuilder2; handler = new StringBuilder.AppendInterpolatedStringHandler(2, 1, stringBuilder2); handler.AppendLiteral(" "); handler.AppendFormatted(item.Trim()); stringBuilder4.AppendLine(ref handler); } } if (num > 0) { stringBuilder.AppendLine("\n다음 단계: 실패한 테스트를 확인하고 관련 코드를 수정한 후 test_loop action=\"run\"으로 다시 실행하세요."); } else { stringBuilder.AppendLine("\n모든 테스트가 통과했습니다."); } return ToolResult.Ok(stringBuilder.ToString()); } private static async Task AutoFixAsync(AgentContext context, CancellationToken ct) { ToolResult runResult = await RunTestsAsync(context, ct); string output = runResult.Output; string[] lines = output.Split('\n'); int failedCount = lines.Count((string l) => l.Contains("FAIL", StringComparison.OrdinalIgnoreCase) || l.Contains("FAILED", StringComparison.OrdinalIgnoreCase)); if (failedCount == 0 && runResult.Success && output.Contains("exit code: 0")) { return ToolResult.Ok("[AUTO_FIX: ALL_PASSED]\n모든 테스트가 통과했습니다. 수정 루프를 종료하세요."); } StringBuilder sb = new StringBuilder(); sb.AppendLine("[AUTO_FIX: FAILURES_DETECTED]"); StringBuilder stringBuilder = sb; StringBuilder stringBuilder2 = stringBuilder; StringBuilder.AppendInterpolatedStringHandler handler = new StringBuilder.AppendInterpolatedStringHandler(10, 1, stringBuilder); handler.AppendLiteral("실패 테스트 수: "); handler.AppendFormatted(failedCount); stringBuilder2.AppendLine(ref handler); sb.AppendLine(); List errors = ExtractFailureDetails(lines); if (errors.Count > 0) { sb.AppendLine("## 실패 상세:"); foreach (FailureDetail err in errors.Take(10)) { stringBuilder = sb; StringBuilder stringBuilder3 = stringBuilder; handler = new StringBuilder.AppendInterpolatedStringHandler(7, 1, stringBuilder); handler.AppendLiteral("- 테스트: "); handler.AppendFormatted(err.TestName); stringBuilder3.AppendLine(ref handler); if (!string.IsNullOrEmpty(err.FilePath)) { stringBuilder = sb; StringBuilder stringBuilder4 = stringBuilder; handler = new StringBuilder.AppendInterpolatedStringHandler(7, 2, stringBuilder); handler.AppendLiteral(" 파일: "); handler.AppendFormatted(err.FilePath); handler.AppendLiteral(":"); handler.AppendFormatted(err.Line); stringBuilder4.AppendLine(ref handler); } stringBuilder = sb; StringBuilder stringBuilder5 = stringBuilder; handler = new StringBuilder.AppendInterpolatedStringHandler(6, 1, stringBuilder); handler.AppendLiteral(" 오류: "); handler.AppendFormatted(err.Message); stringBuilder5.AppendLine(ref handler); sb.AppendLine(); } } sb.AppendLine("## 수정 지침:"); sb.AppendLine("1. 위 오류 메시지에서 원인을 파악하세요"); sb.AppendLine("2. file_read로 관련 파일을 읽고 오류 원인을 확인하세요"); sb.AppendLine("3. file_edit로 코드를 수정하세요"); sb.AppendLine("4. test_loop action=\"auto_fix\"를 다시 호출하여 결과를 확인하세요"); sb.AppendLine("5. 모든 테스트가 통과할 때까지 반복하세요"); int maxIter = (Application.Current as App)?.SettingsService?.Settings.Llm.MaxTestFixIterations ?? 5; stringBuilder = sb; StringBuilder stringBuilder6 = stringBuilder; handler = new StringBuilder.AppendInterpolatedStringHandler(36, 1, stringBuilder); handler.AppendLiteral("\n※ 최대 수정 반복 횟수: "); handler.AppendFormatted(maxIter); handler.AppendLiteral("회. 초과 시 사용자에게 보고하세요."); stringBuilder6.AppendLine(ref handler); sb.AppendLine("\n## 전체 테스트 출력:"); string truncated = ((output.Length > 3000) ? (output.Substring(0, 3000) + "\n... (출력 일부 생략)") : output); sb.AppendLine(truncated); return ToolResult.Ok(sb.ToString()); } private static List ExtractFailureDetails(string[] lines) { List list = new List(); FailureDetail failureDetail = null; for (int i = 0; i < lines.Length; i++) { string text = lines[i].Trim(); if (text.StartsWith("Failed ", StringComparison.OrdinalIgnoreCase) || text.Contains("FAIL!", StringComparison.OrdinalIgnoreCase)) { failureDetail = new FailureDetail { TestName = text }; list.Add(failureDetail); } else if (text.StartsWith("FAILED ", StringComparison.OrdinalIgnoreCase)) { FailureDetail failureDetail2 = new FailureDetail(); string text2 = text; failureDetail2.TestName = text2.Substring(7, text2.Length - 7).Trim(); failureDetail = failureDetail2; list.Add(failureDetail); } else if (failureDetail != null && string.IsNullOrEmpty(failureDetail.FilePath)) { Match match = Regex.Match(text, "([^\\s]+\\.\\w+)[:\\(](\\d+)"); if (match.Success) { failureDetail.FilePath = match.Groups[1].Value; failureDetail.Line = int.Parse(match.Groups[2].Value); } } else if (failureDetail != null && string.IsNullOrEmpty(failureDetail.Message) && (text.Contains("Assert", StringComparison.OrdinalIgnoreCase) || text.Contains("Error", StringComparison.OrdinalIgnoreCase) || text.Contains("Exception", StringComparison.OrdinalIgnoreCase))) { failureDetail.Message = text; } } return list; } private static (string? Cmd, string Args) DetectTestCommand(string workFolder) { if (Directory.EnumerateFiles(workFolder, "*.csproj", SearchOption.AllDirectories).Any() || Directory.EnumerateFiles(workFolder, "*.sln", SearchOption.TopDirectoryOnly).Any()) { return (Cmd: "dotnet", Args: "test --no-build --verbosity normal"); } if (File.Exists(Path.Combine(workFolder, "pytest.ini")) || File.Exists(Path.Combine(workFolder, "setup.py")) || Directory.EnumerateFiles(workFolder, "test_*.py", SearchOption.AllDirectories).Any()) { return (Cmd: "pytest", Args: "--tb=short -q"); } if (File.Exists(Path.Combine(workFolder, "package.json"))) { return (Cmd: "npm", Args: "test -- --passWithNoTests"); } if (Directory.EnumerateFiles(workFolder, "*_test.go", SearchOption.AllDirectories).Any()) { return (Cmd: "go", Args: "test ./..."); } return (Cmd: null, Args: ""); } }