feat: classify and run Maven test verification

This commit is contained in:
张家训
2026-08-04 16:52:37 +08:00
parent 1f736e64e2
commit 544c38d304
10 changed files with 402 additions and 0 deletions
@@ -0,0 +1,50 @@
package com.example.workutils.biz.aitest.maven;
import org.springframework.stereotype.Component;
import java.util.Locale;
@Component
public class MavenErrorClassifier {
public MavenFailureType classify(MavenExecutionResult result) {
if (result.successful()) {
return MavenFailureType.NONE;
}
if (result.command().timedOut()) {
return MavenFailureType.TIMEOUT;
}
String log = result.combinedLog().toLowerCase(Locale.ROOT).replace('\\', '/');
if (containsAny(log, "java_home is not defined", "unsupported class file major version",
"invalid target release", "无效的标记: --release")) {
return MavenFailureType.TOOLCHAIN;
}
if (containsAny(log, "non-resolvable parent pom", "malformed pom", "could not transfer artifact",
"401 unauthorized", "unknown host", "dependencyresolutionexception")) {
return MavenFailureType.DEPENDENCY_OR_POM;
}
if (containsAny(log, "failed to load applicationcontext", "context load failure")) {
return MavenFailureType.TEST_CONTEXT;
}
if (log.contains("src/test/") && containsAny(log, "cannot find symbol", "compilation failure")) {
return MavenFailureType.TEST_COMPILATION;
}
if (log.contains("src/main/") && containsAny(log, "cannot find symbol", "compilation failure")) {
return MavenFailureType.PRODUCTION_COMPILATION;
}
if (!result.testFailures().isEmpty()
|| containsAny(log, "there are test failures", "failed tests:", "tests in error:")) {
return MavenFailureType.TEST_FAILURE;
}
return MavenFailureType.UNKNOWN;
}
private static boolean containsAny(String value, String... markers) {
for (String marker : markers) {
if (value.contains(marker)) {
return true;
}
}
return false;
}
}
@@ -0,0 +1,23 @@
package com.example.workutils.biz.aitest.maven;
import com.example.workutils.biz.aitest.process.CommandResult;
import java.nio.file.Path;
import java.util.List;
public record MavenExecutionResult(MavenPhase phase, CommandResult command,
List<SurefireFailure> testFailures,
List<Path> jacocoReports) {
public MavenExecutionResult {
testFailures = testFailures == null ? List.of() : List.copyOf(testFailures);
jacocoReports = jacocoReports == null ? List.of() : List.copyOf(jacocoReports);
}
public boolean successful() {
return command.successful() && testFailures.isEmpty();
}
public String combinedLog() {
return command.stdout() + System.lineSeparator() + command.stderr();
}
}
@@ -0,0 +1,24 @@
package com.example.workutils.biz.aitest.maven;
public enum MavenFailureType {
NONE(false),
TOOLCHAIN(false),
DEPENDENCY_OR_POM(false),
PRODUCTION_COMPILATION(false),
TEST_COMPILATION(true),
TEST_FAILURE(true),
TEST_CONTEXT(true),
TIMEOUT(false),
COVERAGE(true),
UNKNOWN(false);
private final boolean aiRepairable;
MavenFailureType(boolean aiRepairable) {
this.aiRepairable = aiRepairable;
}
public boolean aiRepairable() {
return aiRepairable;
}
}
@@ -0,0 +1,7 @@
package com.example.workutils.biz.aitest.maven;
public enum MavenPhase {
TEST_COMPILE,
TEST,
JACOCO
}
@@ -0,0 +1,96 @@
package com.example.workutils.biz.aitest.maven;
import com.example.workutils.biz.aitest.config.AiTestSettings;
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 com.example.workutils.biz.aitest.project.MavenModule;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import java.io.File;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Duration;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.function.Consumer;
import java.util.stream.Stream;
@Service
@RequiredArgsConstructor
public class MavenRunner {
private static final String JACOCO_PLUGIN = "org.jacoco:jacoco-maven-plugin:0.8.12";
private final ProcessExecutor processExecutor;
private final SurefireReportParser surefireReportParser;
public MavenExecutionResult run(Path projectRoot, MavenModule module, MavenPhase phase,
AiTestSettings settings, Consumer<String> output) {
Path root = projectRoot.toAbsolutePath().normalize();
List<String> command = commandPrefix(settings);
command.addAll(List.of("-pl", module.relativePath().toString().isBlank()
? "." : module.relativePath().toString(), "-am",
"-DskipTests=false", "-Dmaven.test.skip=false"));
switch (phase) {
case TEST_COMPILE -> command.add("test-compile");
case TEST -> command.add("test");
case JACOCO -> command.addAll(List.of(JACOCO_PLUGIN + ":prepare-agent", "test",
JACOCO_PLUGIN + ":report"));
}
Map<String, String> environment = environment(settings);
CommandResult commandResult = processExecutor.execute(new CommandSpec(root, command,
environment, settings.mavenTimeout()), output);
return new MavenExecutionResult(phase, commandResult,
surefireReportParser.parse(root), findJacocoReports(root));
}
public void cancel() {
processExecutor.cancelActive();
}
private static List<String> commandPrefix(AiTestSettings settings) {
String executable = settings.mavenHome().toString().isBlank()
? (isWindows() ? "mvn.cmd" : "mvn")
: settings.mavenHome().resolve("bin").resolve(isWindows() ? "mvn.cmd" : "mvn").toString();
List<String> command = new ArrayList<>();
if (isWindows()) {
command.add(System.getenv().getOrDefault("COMSPEC", "cmd.exe"));
command.add("/d");
command.add("/c");
}
command.add(executable);
return command;
}
private static Map<String, String> environment(AiTestSettings settings) {
Map<String, String> environment = new HashMap<>();
if (!settings.jdk8Home().toString().isBlank()) {
String javaHome = settings.jdk8Home().toAbsolutePath().normalize().toString();
environment.put("JAVA_HOME", javaHome);
String currentPath = System.getenv().getOrDefault("PATH", "");
environment.put("PATH", Path.of(javaHome, "bin") + File.pathSeparator + currentPath);
}
environment.put("MAVEN_OPTS", "-Dfile.encoding=UTF-8");
return environment;
}
private static List<Path> findJacocoReports(Path root) {
if (Files.notExists(root)) {
return List.of();
}
try (Stream<Path> paths = Files.walk(root)) {
return paths.filter(Files::isRegularFile)
.filter(path -> path.toString().replace('\\', '/').endsWith("target/site/jacoco/jacoco.xml"))
.toList();
} catch (Exception e) {
throw new IllegalStateException("Unable to locate JaCoCo reports", e);
}
}
private static boolean isWindows() {
return System.getProperty("os.name", "").toLowerCase().contains("win");
}
}
@@ -0,0 +1,5 @@
package com.example.workutils.biz.aitest.maven;
public record SurefireFailure(String testClass, String testName, String type,
String message, String stackTrace) {
}
@@ -0,0 +1,75 @@
package com.example.workutils.biz.aitest.maven;
import org.springframework.stereotype.Component;
import org.w3c.dom.Document;
import org.w3c.dom.Element;
import org.w3c.dom.Node;
import org.w3c.dom.NodeList;
import javax.xml.XMLConstants;
import javax.xml.parsers.DocumentBuilderFactory;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.stream.Stream;
@Component
public class SurefireReportParser {
public List<SurefireFailure> parse(Path projectRoot) {
List<SurefireFailure> failures = new ArrayList<>();
if (Files.notExists(projectRoot)) {
return List.of();
}
try (Stream<Path> paths = Files.walk(projectRoot)) {
paths.filter(Files::isRegularFile)
.filter(path -> path.getFileName().toString().startsWith("TEST-"))
.filter(path -> path.getFileName().toString().endsWith(".xml"))
.filter(path -> path.getParent() != null
&& "surefire-reports".equals(path.getParent().getFileName().toString()))
.forEach(path -> parseReport(path, failures));
} catch (Exception e) {
throw new IllegalArgumentException("Unable to read Surefire reports below " + projectRoot, e);
}
return List.copyOf(failures);
}
private static void parseReport(Path report, List<SurefireFailure> failures) {
try {
DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true);
factory.setFeature("http://xml.org/sax/features/external-general-entities", false);
factory.setFeature("http://xml.org/sax/features/external-parameter-entities", false);
factory.setAttribute(XMLConstants.ACCESS_EXTERNAL_DTD, "");
factory.setAttribute(XMLConstants.ACCESS_EXTERNAL_SCHEMA, "");
Document document = factory.newDocumentBuilder().parse(report.toFile());
NodeList cases = document.getElementsByTagName("testcase");
for (int i = 0; i < cases.getLength(); i++) {
Element testCase = (Element) cases.item(i);
Element problem = directChild(testCase, "failure");
if (problem == null) {
problem = directChild(testCase, "error");
}
if (problem != null) {
failures.add(new SurefireFailure(testCase.getAttribute("classname"),
testCase.getAttribute("name"), problem.getAttribute("type"),
problem.getAttribute("message"), problem.getTextContent().trim()));
}
}
} catch (Exception e) {
throw new IllegalArgumentException("Unable to parse Surefire report: " + report, e);
}
}
private static Element directChild(Element parent, String name) {
NodeList nodes = parent.getChildNodes();
for (int i = 0; i < nodes.getLength(); i++) {
Node node = nodes.item(i);
if (node instanceof Element element && name.equals(element.getTagName())) {
return element;
}
}
return null;
}
}
@@ -0,0 +1,32 @@
package com.example.workutils.biz.aitest.maven;
import com.example.workutils.biz.aitest.process.CommandResult;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
import java.time.Duration;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
class MavenErrorClassifierTest {
private final MavenErrorClassifier classifier = new MavenErrorClassifier();
@ParameterizedTest
@CsvSource(value = {
"JAVA_HOME is not defined correctly|TOOLCHAIN",
"Non-resolvable parent POM|DEPENDENCY_OR_POM",
"src/test/java/OrderServiceTest.java:[42,28] cannot find symbol|TEST_COMPILATION",
"src/main/java/OrderService.java:[42,28] cannot find symbol|PRODUCTION_COMPILATION",
"There are test failures|TEST_FAILURE",
"Failed to load ApplicationContext|TEST_CONTEXT"
}, delimiter = '|')
void classifiesMavenFailures(String log, MavenFailureType expected) {
MavenExecutionResult result = new MavenExecutionResult(MavenPhase.TEST,
new CommandResult(1, false, false, log, "", Duration.ofSeconds(1)),
List.of(), List.of());
assertEquals(expected, classifier.classify(result));
}
}
@@ -0,0 +1,56 @@
package com.example.workutils.biz.aitest.maven;
import com.example.workutils.biz.aitest.config.AiTestSettings;
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 com.example.workutils.biz.aitest.project.MavenModule;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Duration;
import java.util.Set;
import java.util.concurrent.atomic.AtomicReference;
import java.util.function.Consumer;
import static org.junit.jupiter.api.Assertions.*;
class MavenRunnerTest {
@Test
void injectsJdk8AndBuildsSelectedModuleWithDependencies(@TempDir Path project) throws Exception {
Path jdk = project.resolve("tools/jdk8");
Path maven = project.resolve("tools/maven");
Files.createDirectories(jdk.resolve("bin"));
Files.createDirectories(maven.resolve("bin"));
Files.createDirectories(project.resolve("app-start"));
AtomicReference<CommandSpec> captured = new AtomicReference<>();
ProcessExecutor executor = new ProcessExecutor() {
@Override public CommandResult execute(CommandSpec spec, Consumer<String> output) {
captured.set(spec);
return new CommandResult(0, false, false, "BUILD SUCCESS", "", Duration.ofSeconds(1));
}
@Override public void cancelActive() { }
};
MavenRunner runner = new MavenRunner(executor, new SurefireReportParser());
AiTestSettings settings = new AiTestSettings(jdk, maven, "codefree", 85, 3,
Duration.ofMinutes(5), Duration.ofMinutes(15));
MavenModule module = new MavenModule(project.resolve("app-start"), Path.of("app-start"),
"com.example", "app-start", "1", Set.of());
MavenExecutionResult result = runner.run(project, module, MavenPhase.TEST_COMPILE,
settings, ignored -> { });
assertTrue(result.successful());
assertEquals(jdk.toAbsolutePath().normalize().toString(), captured.get().environment().get("JAVA_HOME"));
assertTrue(captured.get().command().containsAll(java.util.List.of("-pl", "app-start", "-am", "test-compile")));
assertTrue(captured.get().command().stream()
.anyMatch(value -> value.endsWith(isWindows() ? "mvn.cmd" : "mvn")));
}
private static boolean isWindows() {
return System.getProperty("os.name", "").toLowerCase().contains("win");
}
}
@@ -0,0 +1,34 @@
package com.example.workutils.biz.aitest.maven;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
class SurefireReportParserTest {
@Test
void extractsFailureAndErrorRootDetails(@TempDir Path root) throws Exception {
Path reports = root.resolve("target/surefire-reports");
Files.createDirectories(reports);
Files.writeString(reports.resolve("TEST-a.OrderServiceTest.xml"), """
<testsuite tests="1" failures="1">
<testcase classname="a.OrderServiceTest" name="returnsSuccess">
<failure type="org.opentest4j.AssertionFailedError" message="expected SUCCESS">stack line</failure>
</testcase>
</testsuite>
""", StandardCharsets.UTF_8);
List<SurefireFailure> failures = new SurefireReportParser().parse(root);
assertEquals(1, failures.size());
assertEquals("a.OrderServiceTest", failures.get(0).testClass());
assertEquals("returnsSuccess", failures.get(0).testName());
assertEquals("expected SUCCESS", failures.get(0).message());
}
}