From 544c38d3041f0070b4e75710fdc74f0899eb7735 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:52:37 +0800 Subject: [PATCH] feat: classify and run Maven test verification --- .../aitest/maven/MavenErrorClassifier.java | 50 ++++++++++ .../aitest/maven/MavenExecutionResult.java | 23 +++++ .../biz/aitest/maven/MavenFailureType.java | 24 +++++ .../biz/aitest/maven/MavenPhase.java | 7 ++ .../biz/aitest/maven/MavenRunner.java | 96 +++++++++++++++++++ .../biz/aitest/maven/SurefireFailure.java | 5 + .../aitest/maven/SurefireReportParser.java | 75 +++++++++++++++ .../maven/MavenErrorClassifierTest.java | 32 +++++++ .../biz/aitest/maven/MavenRunnerTest.java | 56 +++++++++++ .../maven/SurefireReportParserTest.java | 34 +++++++ 10 files changed, 402 insertions(+) create mode 100644 src/main/java/com/example/workutils/biz/aitest/maven/MavenErrorClassifier.java create mode 100644 src/main/java/com/example/workutils/biz/aitest/maven/MavenExecutionResult.java create mode 100644 src/main/java/com/example/workutils/biz/aitest/maven/MavenFailureType.java create mode 100644 src/main/java/com/example/workutils/biz/aitest/maven/MavenPhase.java create mode 100644 src/main/java/com/example/workutils/biz/aitest/maven/MavenRunner.java create mode 100644 src/main/java/com/example/workutils/biz/aitest/maven/SurefireFailure.java create mode 100644 src/main/java/com/example/workutils/biz/aitest/maven/SurefireReportParser.java create mode 100644 src/test/java/com/example/workutils/biz/aitest/maven/MavenErrorClassifierTest.java create mode 100644 src/test/java/com/example/workutils/biz/aitest/maven/MavenRunnerTest.java create mode 100644 src/test/java/com/example/workutils/biz/aitest/maven/SurefireReportParserTest.java diff --git a/src/main/java/com/example/workutils/biz/aitest/maven/MavenErrorClassifier.java b/src/main/java/com/example/workutils/biz/aitest/maven/MavenErrorClassifier.java new file mode 100644 index 0000000..177d3cb --- /dev/null +++ b/src/main/java/com/example/workutils/biz/aitest/maven/MavenErrorClassifier.java @@ -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; + } +} diff --git a/src/main/java/com/example/workutils/biz/aitest/maven/MavenExecutionResult.java b/src/main/java/com/example/workutils/biz/aitest/maven/MavenExecutionResult.java new file mode 100644 index 0000000..f8ade4d --- /dev/null +++ b/src/main/java/com/example/workutils/biz/aitest/maven/MavenExecutionResult.java @@ -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 testFailures, + List 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(); + } +} diff --git a/src/main/java/com/example/workutils/biz/aitest/maven/MavenFailureType.java b/src/main/java/com/example/workutils/biz/aitest/maven/MavenFailureType.java new file mode 100644 index 0000000..43b7458 --- /dev/null +++ b/src/main/java/com/example/workutils/biz/aitest/maven/MavenFailureType.java @@ -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; + } +} diff --git a/src/main/java/com/example/workutils/biz/aitest/maven/MavenPhase.java b/src/main/java/com/example/workutils/biz/aitest/maven/MavenPhase.java new file mode 100644 index 0000000..ab4e6c1 --- /dev/null +++ b/src/main/java/com/example/workutils/biz/aitest/maven/MavenPhase.java @@ -0,0 +1,7 @@ +package com.example.workutils.biz.aitest.maven; + +public enum MavenPhase { + TEST_COMPILE, + TEST, + JACOCO +} diff --git a/src/main/java/com/example/workutils/biz/aitest/maven/MavenRunner.java b/src/main/java/com/example/workutils/biz/aitest/maven/MavenRunner.java new file mode 100644 index 0000000..d711e0f --- /dev/null +++ b/src/main/java/com/example/workutils/biz/aitest/maven/MavenRunner.java @@ -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 output) { + Path root = projectRoot.toAbsolutePath().normalize(); + List 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 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 commandPrefix(AiTestSettings settings) { + String executable = settings.mavenHome().toString().isBlank() + ? (isWindows() ? "mvn.cmd" : "mvn") + : settings.mavenHome().resolve("bin").resolve(isWindows() ? "mvn.cmd" : "mvn").toString(); + List 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 environment(AiTestSettings settings) { + Map 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 findJacocoReports(Path root) { + if (Files.notExists(root)) { + return List.of(); + } + try (Stream 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"); + } +} diff --git a/src/main/java/com/example/workutils/biz/aitest/maven/SurefireFailure.java b/src/main/java/com/example/workutils/biz/aitest/maven/SurefireFailure.java new file mode 100644 index 0000000..f028f3b --- /dev/null +++ b/src/main/java/com/example/workutils/biz/aitest/maven/SurefireFailure.java @@ -0,0 +1,5 @@ +package com.example.workutils.biz.aitest.maven; + +public record SurefireFailure(String testClass, String testName, String type, + String message, String stackTrace) { +} diff --git a/src/main/java/com/example/workutils/biz/aitest/maven/SurefireReportParser.java b/src/main/java/com/example/workutils/biz/aitest/maven/SurefireReportParser.java new file mode 100644 index 0000000..b93512c --- /dev/null +++ b/src/main/java/com/example/workutils/biz/aitest/maven/SurefireReportParser.java @@ -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 parse(Path projectRoot) { + List failures = new ArrayList<>(); + if (Files.notExists(projectRoot)) { + return List.of(); + } + try (Stream 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 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; + } +} diff --git a/src/test/java/com/example/workutils/biz/aitest/maven/MavenErrorClassifierTest.java b/src/test/java/com/example/workutils/biz/aitest/maven/MavenErrorClassifierTest.java new file mode 100644 index 0000000..190285e --- /dev/null +++ b/src/test/java/com/example/workutils/biz/aitest/maven/MavenErrorClassifierTest.java @@ -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)); + } +} diff --git a/src/test/java/com/example/workutils/biz/aitest/maven/MavenRunnerTest.java b/src/test/java/com/example/workutils/biz/aitest/maven/MavenRunnerTest.java new file mode 100644 index 0000000..55e10d3 --- /dev/null +++ b/src/test/java/com/example/workutils/biz/aitest/maven/MavenRunnerTest.java @@ -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 captured = new AtomicReference<>(); + ProcessExecutor executor = new ProcessExecutor() { + @Override public CommandResult execute(CommandSpec spec, Consumer 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"); + } +} diff --git a/src/test/java/com/example/workutils/biz/aitest/maven/SurefireReportParserTest.java b/src/test/java/com/example/workutils/biz/aitest/maven/SurefireReportParserTest.java new file mode 100644 index 0000000..25c8e12 --- /dev/null +++ b/src/test/java/com/example/workutils/biz/aitest/maven/SurefireReportParserTest.java @@ -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"), """ + + + stack line + + + """, StandardCharsets.UTF_8); + + List 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()); + } +}