Files

386 lines
14 KiB
C#

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<string, ToolProperty>
{
["action"] = new ToolProperty
{
Type = "string",
Description = "수행할 작업: generate | run | analyze | auto_fix",
Enum = new List<string> { "generate", "run", "analyze", "auto_fix" }
},
["file_path"] = new ToolProperty
{
Type = "string",
Description = "대상 소스 파일 경로 (generate 시 필요)"
},
["test_output"] = new ToolProperty
{
Type = "string",
Description = "분석할 테스트 출력 (analyze 시 필요)"
}
},
Required = new List<string> { "action" }
};
public async Task<ToolResult> 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<ToolResult> 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<string> 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<ToolResult> 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<FailureDetail> 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<FailureDetail> ExtractFailureDetails(string[] lines)
{
List<FailureDetail> list = new List<FailureDetail>();
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: "");
}
}