feat: classify and run Maven test verification
This commit is contained in:
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user