feat: integrate CodeFree test generation
This commit is contained in:
+53
@@ -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);
|
||||
}
|
||||
}
|
||||
+46
@@ -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("正常场景"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user