386 lines
14 KiB
C#
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: "");
|
|
}
|
|
}
|