feat: integrate CodeFree test generation

This commit is contained in:
张家训
2026-08-04 16:49:00 +08:00
parent e344082643
commit 1f736e64e2
11 changed files with 360 additions and 0 deletions
@@ -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<String> output) {
List<String> 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");
}
}
@@ -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<GeneratedTestFile> 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<String> strings(JSONArray values) {
if (values == null) {
return List.of();
}
List<String> result = new ArrayList<>();
for (Object value : values) {
result.add(String.valueOf(value));
}
return result;
}
private static List<Integer> integers(JSONArray values) {
if (values == null) {
return List.of();
}
List<Integer> result = new ArrayList<>();
for (Object value : values) {
result.add(((Number) value).intValue());
}
return result;
}
}
@@ -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<ResolvedGeneratedFile> validateAndResolve(Path moduleRoot,
List<GeneratedTestFile> generatedFiles) {
Path normalizedModule = moduleRoot.toAbsolutePath().normalize();
Path allowedRoot = normalizedModule.resolve("src/test/java").normalize();
List<ResolvedGeneratedFile> 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);
}
}
}
}
@@ -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<Integer> coveredLines, List<String> scenarios) {
public GeneratedTestFile {
coveredLines = coveredLines == null ? List.of() : List.copyOf(coveredLines);
scenarios = scenarios == null ? List.of() : List.copyOf(scenarios);
}
}
@@ -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<Integer> coveredLines, List<String> scenarios) {
}
@@ -0,0 +1,9 @@
package com.example.workutils.biz.aitest.ai;
import java.util.function.Consumer;
public interface TestGenerationProvider {
TestGenerationResult generate(TestGenerationRequest request, Consumer<String> output);
void cancel();
}
@@ -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) {
}
@@ -0,0 +1,19 @@
package com.example.workutils.biz.aitest.ai;
import java.util.List;
public record TestGenerationResult(boolean successful, String action,
List<GeneratedTestFile> tests,
List<String> assumptions, List<String> 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);
}
}
@@ -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<CommandSpec> captured = new AtomicReference<>();
ProcessExecutor executor = new ProcessExecutor() {
@Override
public CommandResult execute(CommandSpec spec, Consumer<String> 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"));
}
}
@@ -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"));
}
}
@@ -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<ResolvedGeneratedFile> 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("正常场景"));
}
}