From 1f736e64e263b7f99e5d599a769a63a26c8cdeaa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=A0=E5=AE=B6=E8=AE=AD?= Date: Tue, 4 Aug 2026 16:49:00 +0800 Subject: [PATCH] feat: integrate CodeFree test generation --- .../ai/CodeFreeCliTestGenerationProvider.java | 53 ++++++++++++ .../biz/aitest/ai/CodeFreeResultParser.java | 83 +++++++++++++++++++ .../biz/aitest/ai/GeneratedFileValidator.java | 47 +++++++++++ .../biz/aitest/ai/GeneratedTestFile.java | 11 +++ .../biz/aitest/ai/ResolvedGeneratedFile.java | 8 ++ .../biz/aitest/ai/TestGenerationProvider.java | 9 ++ .../biz/aitest/ai/TestGenerationRequest.java | 8 ++ .../biz/aitest/ai/TestGenerationResult.java | 19 +++++ ...CodeFreeCliTestGenerationProviderTest.java | 46 ++++++++++ .../aitest/ai/CodeFreeResultParserTest.java | 37 +++++++++ .../aitest/ai/GeneratedFileValidatorTest.java | 39 +++++++++ 11 files changed, 360 insertions(+) create mode 100644 src/main/java/com/example/workutils/biz/aitest/ai/CodeFreeCliTestGenerationProvider.java create mode 100644 src/main/java/com/example/workutils/biz/aitest/ai/CodeFreeResultParser.java create mode 100644 src/main/java/com/example/workutils/biz/aitest/ai/GeneratedFileValidator.java create mode 100644 src/main/java/com/example/workutils/biz/aitest/ai/GeneratedTestFile.java create mode 100644 src/main/java/com/example/workutils/biz/aitest/ai/ResolvedGeneratedFile.java create mode 100644 src/main/java/com/example/workutils/biz/aitest/ai/TestGenerationProvider.java create mode 100644 src/main/java/com/example/workutils/biz/aitest/ai/TestGenerationRequest.java create mode 100644 src/main/java/com/example/workutils/biz/aitest/ai/TestGenerationResult.java create mode 100644 src/test/java/com/example/workutils/biz/aitest/ai/CodeFreeCliTestGenerationProviderTest.java create mode 100644 src/test/java/com/example/workutils/biz/aitest/ai/CodeFreeResultParserTest.java create mode 100644 src/test/java/com/example/workutils/biz/aitest/ai/GeneratedFileValidatorTest.java diff --git a/src/main/java/com/example/workutils/biz/aitest/ai/CodeFreeCliTestGenerationProvider.java b/src/main/java/com/example/workutils/biz/aitest/ai/CodeFreeCliTestGenerationProvider.java new file mode 100644 index 0000000..2deb5e4 --- /dev/null +++ b/src/main/java/com/example/workutils/biz/aitest/ai/CodeFreeCliTestGenerationProvider.java @@ -0,0 +1,53 @@ +package com.example.workutils.biz.aitest.ai; + +import com.example.workutils.biz.aitest.process.CommandResult; +import com.example.workutils.biz.aitest.process.CommandSpec; +import com.example.workutils.biz.aitest.process.ProcessExecutor; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.function.Consumer; + +@Service +@RequiredArgsConstructor +public class CodeFreeCliTestGenerationProvider implements TestGenerationProvider { + + private final ProcessExecutor processExecutor; + private final CodeFreeResultParser parser; + + @Override + public TestGenerationResult generate(TestGenerationRequest request, Consumer output) { + List command = new ArrayList<>(); + if (isWindows()) { + command.add(System.getenv().getOrDefault("COMSPEC", "cmd.exe")); + command.add("/d"); + command.add("/c"); + } + command.add(request.codeFreeCommand()); + command.add(request.prompt()); + command.addAll(List.of("--channel", "CI", "--approval-mode", "plan", + "--max-session-turns", "1", "--output-format", "json")); + CommandResult result = processExecutor.execute(new CommandSpec(request.workingDirectory(), + command, Map.of(), request.timeout()), output); + if (!result.successful()) { + return TestGenerationResult.failure((result.stderr() + System.lineSeparator() + result.stdout()).trim()); + } + try { + return parser.parse(result.stdout()); + } catch (IllegalArgumentException e) { + return TestGenerationResult.failure(e.getMessage()); + } + } + + @Override + public void cancel() { + processExecutor.cancelActive(); + } + + private static boolean isWindows() { + return System.getProperty("os.name", "").toLowerCase().contains("win"); + } +} diff --git a/src/main/java/com/example/workutils/biz/aitest/ai/CodeFreeResultParser.java b/src/main/java/com/example/workutils/biz/aitest/ai/CodeFreeResultParser.java new file mode 100644 index 0000000..efeb074 --- /dev/null +++ b/src/main/java/com/example/workutils/biz/aitest/ai/CodeFreeResultParser.java @@ -0,0 +1,83 @@ +package com.example.workutils.biz.aitest.ai; + +import com.alibaba.fastjson.JSON; +import com.alibaba.fastjson.JSONArray; +import com.alibaba.fastjson.JSONObject; +import org.springframework.stereotype.Component; + +import java.util.ArrayList; +import java.util.List; + +@Component +public class CodeFreeResultParser { + + public TestGenerationResult parse(String cliJson) { + final JSONArray records; + try { + records = JSON.parseArray(cliJson); + } catch (Exception e) { + throw new IllegalArgumentException("CodeFree output is not a JSON array", e); + } + JSONObject resultRecord = null; + for (Object value : records) { + if (value instanceof JSONObject record + && "result".equals(record.getString("type")) + && "success".equals(record.getString("subtype")) + && !record.getBooleanValue("is_error")) { + resultRecord = record; + } + } + if (resultRecord == null) { + throw new IllegalArgumentException("CodeFree output has no successful result record"); + } + String innerText = resultRecord.getString("result"); + if (innerText == null || innerText.contains("```")) { + throw new IllegalArgumentException("CodeFree result must be plain JSON without Markdown fences"); + } + final JSONObject inner; + try { + inner = JSON.parseObject(innerText); + } catch (Exception e) { + throw new IllegalArgumentException("CodeFree result payload is not valid JSON", e); + } + List tests = new ArrayList<>(); + JSONArray testValues = inner.getJSONArray("tests"); + if (testValues != null) { + for (Object value : testValues) { + JSONObject test = (JSONObject) value; + tests.add(new GeneratedTestFile(test.getString("path"), test.getString("content"), + test.getString("targetClass"), integers(test.getJSONArray("coveredLines")), + strings(test.getJSONArray("scenarios")))); + } + } + String action = inner.getString("action"); + if (action == null || action.isBlank()) { + throw new IllegalArgumentException("CodeFree result action is required"); + } + return new TestGenerationResult(true, action, tests, + strings(inner.getJSONArray("assumptions")), strings(inner.getJSONArray("warnings")), + inner.getString("diagnosis")); + } + + private static List strings(JSONArray values) { + if (values == null) { + return List.of(); + } + List result = new ArrayList<>(); + for (Object value : values) { + result.add(String.valueOf(value)); + } + return result; + } + + private static List integers(JSONArray values) { + if (values == null) { + return List.of(); + } + List result = new ArrayList<>(); + for (Object value : values) { + result.add(((Number) value).intValue()); + } + return result; + } +} diff --git a/src/main/java/com/example/workutils/biz/aitest/ai/GeneratedFileValidator.java b/src/main/java/com/example/workutils/biz/aitest/ai/GeneratedFileValidator.java new file mode 100644 index 0000000..465e68d --- /dev/null +++ b/src/main/java/com/example/workutils/biz/aitest/ai/GeneratedFileValidator.java @@ -0,0 +1,47 @@ +package com.example.workutils.biz.aitest.ai; + +import org.springframework.stereotype.Component; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; + +@Component +public class GeneratedFileValidator { + + public List validateAndResolve(Path moduleRoot, + List generatedFiles) { + Path normalizedModule = moduleRoot.toAbsolutePath().normalize(); + Path allowedRoot = normalizedModule.resolve("src/test/java").normalize(); + List result = new ArrayList<>(); + for (GeneratedTestFile generated : generatedFiles) { + if (generated.path() == null || generated.content() == null || generated.content().isBlank()) { + throw new IllegalArgumentException("Generated test path and content are required"); + } + Path relative = Path.of(generated.path()).normalize(); + if (relative.isAbsolute() || relative.startsWith("..")) { + throw new IllegalArgumentException("Generated test path must be module-relative: " + generated.path()); + } + Path resolved = normalizedModule.resolve(relative).normalize(); + if (!resolved.startsWith(allowedRoot) || !resolved.toString().endsWith(".java")) { + throw new IllegalArgumentException("Generated file is outside src/test/java: " + generated.path()); + } + rejectSymlinkPath(normalizedModule, resolved); + result.add(new ResolvedGeneratedFile(resolved, generated.content(), generated.targetClass(), + generated.coveredLines(), generated.scenarios())); + } + return List.copyOf(result); + } + + private static void rejectSymlinkPath(Path moduleRoot, Path target) { + Path current = moduleRoot; + Path relative = moduleRoot.relativize(target); + for (Path part : relative) { + current = current.resolve(part); + if (Files.isSymbolicLink(current)) { + throw new IllegalArgumentException("Generated file path contains a symbolic link: " + current); + } + } + } +} diff --git a/src/main/java/com/example/workutils/biz/aitest/ai/GeneratedTestFile.java b/src/main/java/com/example/workutils/biz/aitest/ai/GeneratedTestFile.java new file mode 100644 index 0000000..2fad271 --- /dev/null +++ b/src/main/java/com/example/workutils/biz/aitest/ai/GeneratedTestFile.java @@ -0,0 +1,11 @@ +package com.example.workutils.biz.aitest.ai; + +import java.util.List; + +public record GeneratedTestFile(String path, String content, String targetClass, + List coveredLines, List scenarios) { + public GeneratedTestFile { + coveredLines = coveredLines == null ? List.of() : List.copyOf(coveredLines); + scenarios = scenarios == null ? List.of() : List.copyOf(scenarios); + } +} diff --git a/src/main/java/com/example/workutils/biz/aitest/ai/ResolvedGeneratedFile.java b/src/main/java/com/example/workutils/biz/aitest/ai/ResolvedGeneratedFile.java new file mode 100644 index 0000000..f2f6d5b --- /dev/null +++ b/src/main/java/com/example/workutils/biz/aitest/ai/ResolvedGeneratedFile.java @@ -0,0 +1,8 @@ +package com.example.workutils.biz.aitest.ai; + +import java.nio.file.Path; +import java.util.List; + +public record ResolvedGeneratedFile(Path path, String content, String targetClass, + List coveredLines, List scenarios) { +} diff --git a/src/main/java/com/example/workutils/biz/aitest/ai/TestGenerationProvider.java b/src/main/java/com/example/workutils/biz/aitest/ai/TestGenerationProvider.java new file mode 100644 index 0000000..2c3a25a --- /dev/null +++ b/src/main/java/com/example/workutils/biz/aitest/ai/TestGenerationProvider.java @@ -0,0 +1,9 @@ +package com.example.workutils.biz.aitest.ai; + +import java.util.function.Consumer; + +public interface TestGenerationProvider { + TestGenerationResult generate(TestGenerationRequest request, Consumer output); + + void cancel(); +} diff --git a/src/main/java/com/example/workutils/biz/aitest/ai/TestGenerationRequest.java b/src/main/java/com/example/workutils/biz/aitest/ai/TestGenerationRequest.java new file mode 100644 index 0000000..1e78c63 --- /dev/null +++ b/src/main/java/com/example/workutils/biz/aitest/ai/TestGenerationRequest.java @@ -0,0 +1,8 @@ +package com.example.workutils.biz.aitest.ai; + +import java.nio.file.Path; +import java.time.Duration; + +public record TestGenerationRequest(Path workingDirectory, String codeFreeCommand, + String prompt, Duration timeout) { +} diff --git a/src/main/java/com/example/workutils/biz/aitest/ai/TestGenerationResult.java b/src/main/java/com/example/workutils/biz/aitest/ai/TestGenerationResult.java new file mode 100644 index 0000000..808a940 --- /dev/null +++ b/src/main/java/com/example/workutils/biz/aitest/ai/TestGenerationResult.java @@ -0,0 +1,19 @@ +package com.example.workutils.biz.aitest.ai; + +import java.util.List; + +public record TestGenerationResult(boolean successful, String action, + List tests, + List assumptions, List warnings, + String diagnosis) { + public TestGenerationResult { + tests = tests == null ? List.of() : List.copyOf(tests); + assumptions = assumptions == null ? List.of() : List.copyOf(assumptions); + warnings = warnings == null ? List.of() : List.copyOf(warnings); + diagnosis = diagnosis == null ? "" : diagnosis; + } + + public static TestGenerationResult failure(String diagnosis) { + return new TestGenerationResult(false, "CLI_ERROR", List.of(), List.of(), List.of(), diagnosis); + } +} diff --git a/src/test/java/com/example/workutils/biz/aitest/ai/CodeFreeCliTestGenerationProviderTest.java b/src/test/java/com/example/workutils/biz/aitest/ai/CodeFreeCliTestGenerationProviderTest.java new file mode 100644 index 0000000..10999a5 --- /dev/null +++ b/src/test/java/com/example/workutils/biz/aitest/ai/CodeFreeCliTestGenerationProviderTest.java @@ -0,0 +1,46 @@ +package com.example.workutils.biz.aitest.ai; + +import com.example.workutils.biz.aitest.process.CommandResult; +import com.example.workutils.biz.aitest.process.CommandSpec; +import com.example.workutils.biz.aitest.process.ProcessExecutor; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.nio.file.Path; +import java.time.Duration; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Consumer; + +import static org.junit.jupiter.api.Assertions.*; + +class CodeFreeCliTestGenerationProviderTest { + + @Test + void invokesOneShotPlanJsonWithoutModelArgument(@TempDir Path workingDirectory) { + AtomicReference captured = new AtomicReference<>(); + ProcessExecutor executor = new ProcessExecutor() { + @Override + public CommandResult execute(CommandSpec spec, Consumer output) { + captured.set(spec); + String inner = "{\"action\":\"WRITE_TESTS\",\"tests\":[],\"assumptions\":[],\"warnings\":[]}"; + String outer = "[{\"type\":\"result\",\"subtype\":\"success\",\"is_error\":false,\"result\":" + + com.alibaba.fastjson.JSON.toJSONString(inner) + "}]"; + return new CommandResult(0, false, false, outer, "", Duration.ofSeconds(1)); + } + + @Override public void cancelActive() { } + }; + CodeFreeCliTestGenerationProvider provider = + new CodeFreeCliTestGenerationProvider(executor, new CodeFreeResultParser()); + + TestGenerationResult result = provider.generate(new TestGenerationRequest( + workingDirectory, "codefree", "只返回JSON", Duration.ofMinutes(5)), ignored -> { }); + + assertTrue(result.successful()); + assertTrue(captured.get().command().containsAll(java.util.List.of( + "--channel", "CI", "--approval-mode", "plan", "--max-session-turns", "1", + "--output-format", "json"))); + assertFalse(captured.get().command().contains("--model")); + assertTrue(captured.get().command().contains("只返回JSON")); + } +} diff --git a/src/test/java/com/example/workutils/biz/aitest/ai/CodeFreeResultParserTest.java b/src/test/java/com/example/workutils/biz/aitest/ai/CodeFreeResultParserTest.java new file mode 100644 index 0000000..8276d7c --- /dev/null +++ b/src/test/java/com/example/workutils/biz/aitest/ai/CodeFreeResultParserTest.java @@ -0,0 +1,37 @@ +package com.example.workutils.biz.aitest.ai; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +class CodeFreeResultParserTest { + + private final CodeFreeResultParser parser = new CodeFreeResultParser(); + + @Test + void extractsLastSuccessfulResultRecord() { + String inner = """ + {"action":"WRITE_TESTS","tests":[{"path":"src/test/java/a/AServiceTest.java", + "content":"package a; class AServiceTest {}","targetClass":"a.AService", + "coveredLines":[12,14],"scenarios":["正常场景"]}],"assumptions":[],"warnings":[]} + """; + String outer = "[{\"type\":\"system\",\"subtype\":\"init\"}," + + "{\"type\":\"result\",\"subtype\":\"success\",\"is_error\":false,\"result\":" + + com.alibaba.fastjson.JSON.toJSONString(inner) + "}]"; + + TestGenerationResult result = parser.parse(outer); + + assertTrue(result.successful()); + assertEquals("WRITE_TESTS", result.action()); + assertEquals(1, result.tests().size()); + assertEquals("a.AService", result.tests().get(0).targetClass()); + } + + @Test + void rejectsMarkdownWrappedOrMalformedInnerResults() { + String outer = "[{\"type\":\"result\",\"subtype\":\"success\",\"result\":\"```json {} ```\"}]"; + + assertThrows(IllegalArgumentException.class, () -> parser.parse(outer)); + assertThrows(IllegalArgumentException.class, () -> parser.parse("not-json")); + } +} diff --git a/src/test/java/com/example/workutils/biz/aitest/ai/GeneratedFileValidatorTest.java b/src/test/java/com/example/workutils/biz/aitest/ai/GeneratedFileValidatorTest.java new file mode 100644 index 0000000..a0d2ff0 --- /dev/null +++ b/src/test/java/com/example/workutils/biz/aitest/ai/GeneratedFileValidatorTest.java @@ -0,0 +1,39 @@ +package com.example.workutils.biz.aitest.ai; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.nio.file.Path; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +class GeneratedFileValidatorTest { + + private final GeneratedFileValidator validator = new GeneratedFileValidator(); + + @Test + void resolvesOnlyJavaTestsBelowSelectedModule(@TempDir Path module) { + List files = validator.validateAndResolve(module, + List.of(file("src/test/java/a/AServiceTest.java"))); + + assertEquals(module.resolve("src/test/java/a/AServiceTest.java").normalize(), files.get(0).path()); + } + + @Test + void rejectsProductionTraversalAbsoluteAndNonJavaPaths(@TempDir Path module) { + assertThrows(IllegalArgumentException.class, + () -> validator.validateAndResolve(module, List.of(file("../src/main/java/A.java")))); + assertThrows(IllegalArgumentException.class, + () -> validator.validateAndResolve(module, List.of(file("src/main/java/A.java")))); + assertThrows(IllegalArgumentException.class, + () -> validator.validateAndResolve(module, List.of(file(module.resolve("A.java").toString())))); + assertThrows(IllegalArgumentException.class, + () -> validator.validateAndResolve(module, List.of(file("src/test/java/a/data.json")))); + } + + private static GeneratedTestFile file(String path) { + return new GeneratedTestFile(path, "package a; class AServiceTest {}", "a.AService", + List.of(12), List.of("正常场景")); + } +}