From bc70dc894de467b443ea3353358ac549f1bf854c Mon Sep 17 00:00:00 2001 From: Yevhenii Vlasenko Date: Thu, 11 Sep 2025 12:16:47 +0300 Subject: [PATCH 01/44] Added new rid generator. --- .../java/io/testomat/core/RidGenerator.java | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 java-reporter-core/src/main/java/io/testomat/core/RidGenerator.java diff --git a/java-reporter-core/src/main/java/io/testomat/core/RidGenerator.java b/java-reporter-core/src/main/java/io/testomat/core/RidGenerator.java new file mode 100644 index 0000000..08a4c74 --- /dev/null +++ b/java-reporter-core/src/main/java/io/testomat/core/RidGenerator.java @@ -0,0 +1,34 @@ +package io.testomat.core; + +import io.testomat.core.artifact.ArtifactManagementException; +import java.lang.reflect.Method; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public class RidGenerator { + private static final Logger log = LoggerFactory.getLogger(RidGenerator.class); + private static final String MD5_ALGORITHM = "MD5"; + + public static String generate(Method method) { + MessageDigest messageDigest; + try { + messageDigest = MessageDigest.getInstance(MD5_ALGORITHM); + } catch (NoSuchAlgorithmException e) { + log.debug("Failed to implement MessageDigest algorithm: " + MD5_ALGORITHM); + throw new ArtifactManagementException("No such algorithm for MessageDigest: " + + MD5_ALGORITHM); + } + String identifier = method.getDeclaringClass().getCanonicalName() + "." + method.getName(); + byte[] hash = messageDigest.digest(identifier.getBytes(StandardCharsets.UTF_8)); + + StringBuilder sb = new StringBuilder(); + for (byte b : hash) { + sb.append(String.format("%02x", b)); + } + + return method.getName() + ":" + sb; + } +} From 3a3ef1d1962c7272a25777f940e6845240465037 Mon Sep 17 00:00:00 2001 From: Yevhenii Vlasenko Date: Thu, 11 Sep 2025 12:17:12 +0300 Subject: [PATCH 02/44] Added ArtifactManagementException --- .../core/artifact/ArtifactManagementException.java | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 java-reporter-core/src/main/java/io/testomat/core/artifact/ArtifactManagementException.java diff --git a/java-reporter-core/src/main/java/io/testomat/core/artifact/ArtifactManagementException.java b/java-reporter-core/src/main/java/io/testomat/core/artifact/ArtifactManagementException.java new file mode 100644 index 0000000..8949bbd --- /dev/null +++ b/java-reporter-core/src/main/java/io/testomat/core/artifact/ArtifactManagementException.java @@ -0,0 +1,7 @@ +package io.testomat.core.artifact; + +public class ArtifactManagementException extends RuntimeException { + public ArtifactManagementException(String message) { + super(message); + } +} From 1943ff8978aac7c35dc4b011886339708e137690 Mon Sep 17 00:00:00 2001 From: Yevhenii Vlasenko Date: Thu, 11 Sep 2025 12:32:23 +0300 Subject: [PATCH 03/44] Added Testomatio facade class and ServiceRegistry --- .../io/testomat/core/ServiceRegistry.java | 19 +++++++++++++++++++ .../core/artifact/ArtifactManager.java | 9 +++++++++ .../io/testomat/core/artifact/Testomatio.java | 14 ++++++++++++++ 3 files changed, 42 insertions(+) create mode 100644 java-reporter-core/src/main/java/io/testomat/core/ServiceRegistry.java create mode 100644 java-reporter-core/src/main/java/io/testomat/core/artifact/ArtifactManager.java create mode 100644 java-reporter-core/src/main/java/io/testomat/core/artifact/Testomatio.java diff --git a/java-reporter-core/src/main/java/io/testomat/core/ServiceRegistry.java b/java-reporter-core/src/main/java/io/testomat/core/ServiceRegistry.java new file mode 100644 index 0000000..23616d0 --- /dev/null +++ b/java-reporter-core/src/main/java/io/testomat/core/ServiceRegistry.java @@ -0,0 +1,19 @@ +package io.testomat.core; + +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +public class ServiceRegistry { + private static final Map, Object> services = new ConcurrentHashMap<>(); + + @SuppressWarnings("unchecked") + public static T getService(Class serviceClass) { + return (T) services.computeIfAbsent(serviceClass, clazz -> { + try { + return clazz.getDeclaredConstructor().newInstance(); + } catch (Exception e) { + throw new RuntimeException("Cannot create service: " + clazz, e); + } + }); + } +} \ No newline at end of file diff --git a/java-reporter-core/src/main/java/io/testomat/core/artifact/ArtifactManager.java b/java-reporter-core/src/main/java/io/testomat/core/artifact/ArtifactManager.java new file mode 100644 index 0000000..918ace1 --- /dev/null +++ b/java-reporter-core/src/main/java/io/testomat/core/artifact/ArtifactManager.java @@ -0,0 +1,9 @@ +package io.testomat.core.artifact; + +import java.lang.reflect.Method; + +public class ArtifactManager { + public void manageArtifact(Method method, String... directories) { + + } +} diff --git a/java-reporter-core/src/main/java/io/testomat/core/artifact/Testomatio.java b/java-reporter-core/src/main/java/io/testomat/core/artifact/Testomatio.java new file mode 100644 index 0000000..5dd7f0f --- /dev/null +++ b/java-reporter-core/src/main/java/io/testomat/core/artifact/Testomatio.java @@ -0,0 +1,14 @@ +package io.testomat.core.artifact; + +import io.testomat.core.ServiceRegistry; +import java.lang.reflect.Method; +import java.util.List; +import java.util.concurrent.ConcurrentHashMap; + +public class Testomatio { + private static final ConcurrentHashMap> ARTIFACT_STORAGE = new ConcurrentHashMap<>(); + + public static void artifact(Method testMethod, String... directories) { + ServiceRegistry.getService(ArtifactManager.class).manageArtifact(testMethod, directories); + } +} From 85688f97e783069a1c53e11ba324288355e3157e Mon Sep 17 00:00:00 2001 From: Yevhenii Vlasenko Date: Thu, 11 Sep 2025 13:20:04 +0300 Subject: [PATCH 04/44] Added CVS writer --- .../core/artifact/ArtifactCsvWriter.java | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 java-reporter-core/src/main/java/io/testomat/core/artifact/ArtifactCsvWriter.java diff --git a/java-reporter-core/src/main/java/io/testomat/core/artifact/ArtifactCsvWriter.java b/java-reporter-core/src/main/java/io/testomat/core/artifact/ArtifactCsvWriter.java new file mode 100644 index 0000000..800a174 --- /dev/null +++ b/java-reporter-core/src/main/java/io/testomat/core/artifact/ArtifactCsvWriter.java @@ -0,0 +1,36 @@ +package io.testomat.core.artifact; + +import io.testomat.core.exception.ArtifactManagementException; +import java.io.FileWriter; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public class ArtifactCsvWriter { + private static final Logger log = LoggerFactory.getLogger(ArtifactCsvWriter.class); + private static final String CSV_SEPARATOR = ","; + private static final String CSV_HEADER = "rid,directory\n"; + + public static void writeToCSV(String rid, String dir) { + Path csvPath = Paths.get("testomatioArtifacts.csv"); + + try { + boolean fileExists = Files.exists(csvPath); + + try (FileWriter writer = new FileWriter(csvPath.toFile(), true)) { + if (!fileExists) { + writer.write(CSV_HEADER); + } + + writer.write(rid + CSV_SEPARATOR + dir + "\n"); + log.debug("Written to CSV: rid={}, dir={}", rid, dir); + } + } catch (IOException e) { + log.error("Failed to write to CSV file: {}", csvPath, e); + throw new ArtifactManagementException("Failed to write to CSV file: " + csvPath); + } + } +} \ No newline at end of file From e853eaf122c5b66674660d9688a93a611a6dcf3f Mon Sep 17 00:00:00 2001 From: Yevhenii Vlasenko Date: Thu, 11 Sep 2025 13:21:00 +0300 Subject: [PATCH 05/44] Implemented ArtifactManager.java --- .../core/artifact/ArtifactHandler.java | 10 ++++ .../core/artifact/ArtifactManager.java | 60 +++++++++++++++++++ .../core/artifact/DelayedArtifactFormats.java | 17 ++++++ .../core/artifact/DelayedArtifactHandler.java | 10 ++++ .../ArtifactManagementException.java | 2 +- .../core/{artifact => facade}/Testomatio.java | 7 ++- .../RidGeneratorUtil.java} | 8 +-- .../ServiceRegistryUtil.java} | 4 +- 8 files changed, 108 insertions(+), 10 deletions(-) create mode 100644 java-reporter-core/src/main/java/io/testomat/core/artifact/ArtifactHandler.java create mode 100644 java-reporter-core/src/main/java/io/testomat/core/artifact/DelayedArtifactFormats.java create mode 100644 java-reporter-core/src/main/java/io/testomat/core/artifact/DelayedArtifactHandler.java rename java-reporter-core/src/main/java/io/testomat/core/{artifact => exception}/ArtifactManagementException.java (81%) rename java-reporter-core/src/main/java/io/testomat/core/{artifact => facade}/Testomatio.java (58%) rename java-reporter-core/src/main/java/io/testomat/core/{RidGenerator.java => util/RidGeneratorUtil.java} (88%) rename java-reporter-core/src/main/java/io/testomat/core/{ServiceRegistry.java => util/ServiceRegistryUtil.java} (89%) diff --git a/java-reporter-core/src/main/java/io/testomat/core/artifact/ArtifactHandler.java b/java-reporter-core/src/main/java/io/testomat/core/artifact/ArtifactHandler.java new file mode 100644 index 0000000..d0c397a --- /dev/null +++ b/java-reporter-core/src/main/java/io/testomat/core/artifact/ArtifactHandler.java @@ -0,0 +1,10 @@ +package io.testomat.core.artifact; + +import java.lang.reflect.Method; + +public class ArtifactHandler { + + public void handleArtifact(Method method, String dir) { + + } +} diff --git a/java-reporter-core/src/main/java/io/testomat/core/artifact/ArtifactManager.java b/java-reporter-core/src/main/java/io/testomat/core/artifact/ArtifactManager.java index 918ace1..b21d655 100644 --- a/java-reporter-core/src/main/java/io/testomat/core/artifact/ArtifactManager.java +++ b/java-reporter-core/src/main/java/io/testomat/core/artifact/ArtifactManager.java @@ -1,9 +1,69 @@ package io.testomat.core.artifact; +import io.testomat.core.exception.ArtifactManagementException; import java.lang.reflect.Method; +import java.nio.file.Files; +import java.nio.file.InvalidPathException; +import java.nio.file.Path; +import java.nio.file.Paths; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; public class ArtifactManager { + private static final Logger log = LoggerFactory.getLogger(ArtifactManager.class); + private final DelayedArtifactHandler delayedArtifactHandler; + private final ArtifactHandler artifactHandler; + + public ArtifactManager() { + this.artifactHandler = new ArtifactHandler(); + this.delayedArtifactHandler = new DelayedArtifactHandler(); + } + + public ArtifactManager(DelayedArtifactHandler delayedArtifactHandler, ArtifactHandler artifactHandler) { + this.delayedArtifactHandler = delayedArtifactHandler; + this.artifactHandler = artifactHandler; + } + public void manageArtifact(Method method, String... directories) { + if (method == null) { + throw new ArtifactManagementException("Received null method into ArtifactManager"); + } + for (String dir : directories) { + try { + if (isValidFilePath(dir)) { + if (shouldArtifactBeDelayed(dir)) { + delayedArtifactHandler.handleArtifact(method, dir); + } else { + artifactHandler.handleArtifact(method, dir); + } + } + } catch (InvalidPathException | SecurityException e) { + log.warn("Failed to validate path: {}", dir, e); + } + } + } + + private boolean shouldArtifactBeDelayed(String dir) { + for (String format : DelayedArtifactFormats.FORMATS) { + if (dir.endsWith(format)) { + return true; + } + } + return false; + } + + + public static boolean isValidFilePath(String filePath) { + if (filePath == null || filePath.trim().isEmpty()) { + return false; + } + try { + Path path = Paths.get(filePath); + return Files.exists(path) && Files.isRegularFile(path); + } catch (InvalidPathException e) { + log.warn("Provided filepath is not valid: {}", filePath); + return false; + } } } diff --git a/java-reporter-core/src/main/java/io/testomat/core/artifact/DelayedArtifactFormats.java b/java-reporter-core/src/main/java/io/testomat/core/artifact/DelayedArtifactFormats.java new file mode 100644 index 0000000..da32a79 --- /dev/null +++ b/java-reporter-core/src/main/java/io/testomat/core/artifact/DelayedArtifactFormats.java @@ -0,0 +1,17 @@ +package io.testomat.core.artifact; + +import java.util.Arrays; +import java.util.List; + +public class DelayedArtifactFormats { + + public static final List FORMATS = Arrays.asList( + "mp4", + "avi", + "mkv", + "mov", + "zip", + "rar", + "7z" + ); +} \ No newline at end of file diff --git a/java-reporter-core/src/main/java/io/testomat/core/artifact/DelayedArtifactHandler.java b/java-reporter-core/src/main/java/io/testomat/core/artifact/DelayedArtifactHandler.java new file mode 100644 index 0000000..7ed37df --- /dev/null +++ b/java-reporter-core/src/main/java/io/testomat/core/artifact/DelayedArtifactHandler.java @@ -0,0 +1,10 @@ +package io.testomat.core.artifact; + +import java.lang.reflect.Method; + +public class DelayedArtifactHandler { + + public void handleArtifact(Method method, String dir){ + + } +} diff --git a/java-reporter-core/src/main/java/io/testomat/core/artifact/ArtifactManagementException.java b/java-reporter-core/src/main/java/io/testomat/core/exception/ArtifactManagementException.java similarity index 81% rename from java-reporter-core/src/main/java/io/testomat/core/artifact/ArtifactManagementException.java rename to java-reporter-core/src/main/java/io/testomat/core/exception/ArtifactManagementException.java index 8949bbd..c0d6910 100644 --- a/java-reporter-core/src/main/java/io/testomat/core/artifact/ArtifactManagementException.java +++ b/java-reporter-core/src/main/java/io/testomat/core/exception/ArtifactManagementException.java @@ -1,4 +1,4 @@ -package io.testomat.core.artifact; +package io.testomat.core.exception; public class ArtifactManagementException extends RuntimeException { public ArtifactManagementException(String message) { diff --git a/java-reporter-core/src/main/java/io/testomat/core/artifact/Testomatio.java b/java-reporter-core/src/main/java/io/testomat/core/facade/Testomatio.java similarity index 58% rename from java-reporter-core/src/main/java/io/testomat/core/artifact/Testomatio.java rename to java-reporter-core/src/main/java/io/testomat/core/facade/Testomatio.java index 5dd7f0f..201babf 100644 --- a/java-reporter-core/src/main/java/io/testomat/core/artifact/Testomatio.java +++ b/java-reporter-core/src/main/java/io/testomat/core/facade/Testomatio.java @@ -1,6 +1,7 @@ -package io.testomat.core.artifact; +package io.testomat.core.facade; -import io.testomat.core.ServiceRegistry; +import io.testomat.core.util.ServiceRegistryUtil; +import io.testomat.core.artifact.ArtifactManager; import java.lang.reflect.Method; import java.util.List; import java.util.concurrent.ConcurrentHashMap; @@ -9,6 +10,6 @@ public class Testomatio { private static final ConcurrentHashMap> ARTIFACT_STORAGE = new ConcurrentHashMap<>(); public static void artifact(Method testMethod, String... directories) { - ServiceRegistry.getService(ArtifactManager.class).manageArtifact(testMethod, directories); + ServiceRegistryUtil.getService(ArtifactManager.class).manageArtifact(testMethod, directories); } } diff --git a/java-reporter-core/src/main/java/io/testomat/core/RidGenerator.java b/java-reporter-core/src/main/java/io/testomat/core/util/RidGeneratorUtil.java similarity index 88% rename from java-reporter-core/src/main/java/io/testomat/core/RidGenerator.java rename to java-reporter-core/src/main/java/io/testomat/core/util/RidGeneratorUtil.java index 08a4c74..c3e8cb4 100644 --- a/java-reporter-core/src/main/java/io/testomat/core/RidGenerator.java +++ b/java-reporter-core/src/main/java/io/testomat/core/util/RidGeneratorUtil.java @@ -1,6 +1,6 @@ -package io.testomat.core; +package io.testomat.core.util; -import io.testomat.core.artifact.ArtifactManagementException; +import io.testomat.core.exception.ArtifactManagementException; import java.lang.reflect.Method; import java.nio.charset.StandardCharsets; import java.security.MessageDigest; @@ -8,8 +8,8 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; -public class RidGenerator { - private static final Logger log = LoggerFactory.getLogger(RidGenerator.class); +public class RidGeneratorUtil { + private static final Logger log = LoggerFactory.getLogger(RidGeneratorUtil.class); private static final String MD5_ALGORITHM = "MD5"; public static String generate(Method method) { diff --git a/java-reporter-core/src/main/java/io/testomat/core/ServiceRegistry.java b/java-reporter-core/src/main/java/io/testomat/core/util/ServiceRegistryUtil.java similarity index 89% rename from java-reporter-core/src/main/java/io/testomat/core/ServiceRegistry.java rename to java-reporter-core/src/main/java/io/testomat/core/util/ServiceRegistryUtil.java index 23616d0..72465e8 100644 --- a/java-reporter-core/src/main/java/io/testomat/core/ServiceRegistry.java +++ b/java-reporter-core/src/main/java/io/testomat/core/util/ServiceRegistryUtil.java @@ -1,9 +1,9 @@ -package io.testomat.core; +package io.testomat.core.util; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; -public class ServiceRegistry { +public class ServiceRegistryUtil { private static final Map, Object> services = new ConcurrentHashMap<>(); @SuppressWarnings("unchecked") From e193c9ad81bef654d0bef454cc273935642b957c Mon Sep 17 00:00:00 2001 From: Yevhenii Vlasenko Date: Fri, 12 Sep 2025 14:55:12 +0300 Subject: [PATCH 06/44] logic change to temporal storage. (deleted excess files, not fully implemented) --- .../core/artifact/ArtifactCsvWriter.java | 36 --------------- .../core/artifact/ArtifactHandler.java | 10 ----- .../core/artifact/ArtifactManager.java | 45 +++---------------- .../core/artifact/DelayedArtifactFormats.java | 17 ------- .../core/artifact/DelayedArtifactHandler.java | 10 ----- .../artifact/TemporalArtifactStorage.java | 12 +++++ .../ArtifactManagementException.java | 4 ++ .../{util => facade}/ServiceRegistryUtil.java | 2 +- .../io/testomat/core/facade/Testomatio.java | 6 +-- .../testomat/core/util/RidGeneratorUtil.java | 34 -------------- .../junit/listener/JunitListener.java | 11 ++++- 11 files changed, 36 insertions(+), 151 deletions(-) delete mode 100644 java-reporter-core/src/main/java/io/testomat/core/artifact/ArtifactCsvWriter.java delete mode 100644 java-reporter-core/src/main/java/io/testomat/core/artifact/ArtifactHandler.java delete mode 100644 java-reporter-core/src/main/java/io/testomat/core/artifact/DelayedArtifactFormats.java delete mode 100644 java-reporter-core/src/main/java/io/testomat/core/artifact/DelayedArtifactHandler.java create mode 100644 java-reporter-core/src/main/java/io/testomat/core/artifact/TemporalArtifactStorage.java rename java-reporter-core/src/main/java/io/testomat/core/{util => facade}/ServiceRegistryUtil.java (94%) delete mode 100644 java-reporter-core/src/main/java/io/testomat/core/util/RidGeneratorUtil.java diff --git a/java-reporter-core/src/main/java/io/testomat/core/artifact/ArtifactCsvWriter.java b/java-reporter-core/src/main/java/io/testomat/core/artifact/ArtifactCsvWriter.java deleted file mode 100644 index 800a174..0000000 --- a/java-reporter-core/src/main/java/io/testomat/core/artifact/ArtifactCsvWriter.java +++ /dev/null @@ -1,36 +0,0 @@ -package io.testomat.core.artifact; - -import io.testomat.core.exception.ArtifactManagementException; -import java.io.FileWriter; -import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Path; -import java.nio.file.Paths; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -public class ArtifactCsvWriter { - private static final Logger log = LoggerFactory.getLogger(ArtifactCsvWriter.class); - private static final String CSV_SEPARATOR = ","; - private static final String CSV_HEADER = "rid,directory\n"; - - public static void writeToCSV(String rid, String dir) { - Path csvPath = Paths.get("testomatioArtifacts.csv"); - - try { - boolean fileExists = Files.exists(csvPath); - - try (FileWriter writer = new FileWriter(csvPath.toFile(), true)) { - if (!fileExists) { - writer.write(CSV_HEADER); - } - - writer.write(rid + CSV_SEPARATOR + dir + "\n"); - log.debug("Written to CSV: rid={}, dir={}", rid, dir); - } - } catch (IOException e) { - log.error("Failed to write to CSV file: {}", csvPath, e); - throw new ArtifactManagementException("Failed to write to CSV file: " + csvPath); - } - } -} \ No newline at end of file diff --git a/java-reporter-core/src/main/java/io/testomat/core/artifact/ArtifactHandler.java b/java-reporter-core/src/main/java/io/testomat/core/artifact/ArtifactHandler.java deleted file mode 100644 index d0c397a..0000000 --- a/java-reporter-core/src/main/java/io/testomat/core/artifact/ArtifactHandler.java +++ /dev/null @@ -1,10 +0,0 @@ -package io.testomat.core.artifact; - -import java.lang.reflect.Method; - -public class ArtifactHandler { - - public void handleArtifact(Method method, String dir) { - - } -} diff --git a/java-reporter-core/src/main/java/io/testomat/core/artifact/ArtifactManager.java b/java-reporter-core/src/main/java/io/testomat/core/artifact/ArtifactManager.java index b21d655..9c49760 100644 --- a/java-reporter-core/src/main/java/io/testomat/core/artifact/ArtifactManager.java +++ b/java-reporter-core/src/main/java/io/testomat/core/artifact/ArtifactManager.java @@ -1,7 +1,6 @@ package io.testomat.core.artifact; import io.testomat.core.exception.ArtifactManagementException; -import java.lang.reflect.Method; import java.nio.file.Files; import java.nio.file.InvalidPathException; import java.nio.file.Path; @@ -11,59 +10,29 @@ public class ArtifactManager { private static final Logger log = LoggerFactory.getLogger(ArtifactManager.class); - private final DelayedArtifactHandler delayedArtifactHandler; - private final ArtifactHandler artifactHandler; - public ArtifactManager() { - this.artifactHandler = new ArtifactHandler(); - this.delayedArtifactHandler = new DelayedArtifactHandler(); - } - - public ArtifactManager(DelayedArtifactHandler delayedArtifactHandler, ArtifactHandler artifactHandler) { - this.delayedArtifactHandler = delayedArtifactHandler; - this.artifactHandler = artifactHandler; - } - - public void manageArtifact(Method method, String... directories) { - if (method == null) { - throw new ArtifactManagementException("Received null method into ArtifactManager"); - } + public void manageArtifact(String... directories) { for (String dir : directories) { - try { - if (isValidFilePath(dir)) { - if (shouldArtifactBeDelayed(dir)) { - delayedArtifactHandler.handleArtifact(method, dir); - } else { - artifactHandler.handleArtifact(method, dir); - } - } - } catch (InvalidPathException | SecurityException e) { - log.warn("Failed to validate path: {}", dir, e); + if (isValidFilePath(dir)) { + TemporalArtifactStorage.store(dir); + } else { + log.info("Invalid artifact path provided: {}", dir); } } } - private boolean shouldArtifactBeDelayed(String dir) { - for (String format : DelayedArtifactFormats.FORMATS) { - if (dir.endsWith(format)) { - return true; - } - } - return false; - } - - public static boolean isValidFilePath(String filePath) { if (filePath == null || filePath.trim().isEmpty()) { return false; } - try { Path path = Paths.get(filePath); return Files.exists(path) && Files.isRegularFile(path); } catch (InvalidPathException e) { log.warn("Provided filepath is not valid: {}", filePath); return false; + } catch (Exception e) { + throw new ArtifactManagementException("Unknown exception while file path validation", e); } } } diff --git a/java-reporter-core/src/main/java/io/testomat/core/artifact/DelayedArtifactFormats.java b/java-reporter-core/src/main/java/io/testomat/core/artifact/DelayedArtifactFormats.java deleted file mode 100644 index da32a79..0000000 --- a/java-reporter-core/src/main/java/io/testomat/core/artifact/DelayedArtifactFormats.java +++ /dev/null @@ -1,17 +0,0 @@ -package io.testomat.core.artifact; - -import java.util.Arrays; -import java.util.List; - -public class DelayedArtifactFormats { - - public static final List FORMATS = Arrays.asList( - "mp4", - "avi", - "mkv", - "mov", - "zip", - "rar", - "7z" - ); -} \ No newline at end of file diff --git a/java-reporter-core/src/main/java/io/testomat/core/artifact/DelayedArtifactHandler.java b/java-reporter-core/src/main/java/io/testomat/core/artifact/DelayedArtifactHandler.java deleted file mode 100644 index 7ed37df..0000000 --- a/java-reporter-core/src/main/java/io/testomat/core/artifact/DelayedArtifactHandler.java +++ /dev/null @@ -1,10 +0,0 @@ -package io.testomat.core.artifact; - -import java.lang.reflect.Method; - -public class DelayedArtifactHandler { - - public void handleArtifact(Method method, String dir){ - - } -} diff --git a/java-reporter-core/src/main/java/io/testomat/core/artifact/TemporalArtifactStorage.java b/java-reporter-core/src/main/java/io/testomat/core/artifact/TemporalArtifactStorage.java new file mode 100644 index 0000000..7303424 --- /dev/null +++ b/java-reporter-core/src/main/java/io/testomat/core/artifact/TemporalArtifactStorage.java @@ -0,0 +1,12 @@ +package io.testomat.core.artifact; + +import java.util.ArrayList; +import java.util.List; + +public class TemporalArtifactStorage { + public static final ThreadLocal> DIRECTORIES = ThreadLocal.withInitial(ArrayList::new); + + public static void store(String dir) { + DIRECTORIES.get().add(dir); + } +} diff --git a/java-reporter-core/src/main/java/io/testomat/core/exception/ArtifactManagementException.java b/java-reporter-core/src/main/java/io/testomat/core/exception/ArtifactManagementException.java index c0d6910..0781301 100644 --- a/java-reporter-core/src/main/java/io/testomat/core/exception/ArtifactManagementException.java +++ b/java-reporter-core/src/main/java/io/testomat/core/exception/ArtifactManagementException.java @@ -4,4 +4,8 @@ public class ArtifactManagementException extends RuntimeException { public ArtifactManagementException(String message) { super(message); } + + public ArtifactManagementException(String message, Throwable throwable) { + super(message, throwable); + } } diff --git a/java-reporter-core/src/main/java/io/testomat/core/util/ServiceRegistryUtil.java b/java-reporter-core/src/main/java/io/testomat/core/facade/ServiceRegistryUtil.java similarity index 94% rename from java-reporter-core/src/main/java/io/testomat/core/util/ServiceRegistryUtil.java rename to java-reporter-core/src/main/java/io/testomat/core/facade/ServiceRegistryUtil.java index 72465e8..3c4ebcf 100644 --- a/java-reporter-core/src/main/java/io/testomat/core/util/ServiceRegistryUtil.java +++ b/java-reporter-core/src/main/java/io/testomat/core/facade/ServiceRegistryUtil.java @@ -1,4 +1,4 @@ -package io.testomat.core.util; +package io.testomat.core.facade; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; diff --git a/java-reporter-core/src/main/java/io/testomat/core/facade/Testomatio.java b/java-reporter-core/src/main/java/io/testomat/core/facade/Testomatio.java index 201babf..a565efb 100644 --- a/java-reporter-core/src/main/java/io/testomat/core/facade/Testomatio.java +++ b/java-reporter-core/src/main/java/io/testomat/core/facade/Testomatio.java @@ -1,15 +1,13 @@ package io.testomat.core.facade; -import io.testomat.core.util.ServiceRegistryUtil; import io.testomat.core.artifact.ArtifactManager; -import java.lang.reflect.Method; import java.util.List; import java.util.concurrent.ConcurrentHashMap; public class Testomatio { private static final ConcurrentHashMap> ARTIFACT_STORAGE = new ConcurrentHashMap<>(); - public static void artifact(Method testMethod, String... directories) { - ServiceRegistryUtil.getService(ArtifactManager.class).manageArtifact(testMethod, directories); + public static void artifact(String... directories) { + ServiceRegistryUtil.getService(ArtifactManager.class).manageArtifact(directories); } } diff --git a/java-reporter-core/src/main/java/io/testomat/core/util/RidGeneratorUtil.java b/java-reporter-core/src/main/java/io/testomat/core/util/RidGeneratorUtil.java deleted file mode 100644 index c3e8cb4..0000000 --- a/java-reporter-core/src/main/java/io/testomat/core/util/RidGeneratorUtil.java +++ /dev/null @@ -1,34 +0,0 @@ -package io.testomat.core.util; - -import io.testomat.core.exception.ArtifactManagementException; -import java.lang.reflect.Method; -import java.nio.charset.StandardCharsets; -import java.security.MessageDigest; -import java.security.NoSuchAlgorithmException; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -public class RidGeneratorUtil { - private static final Logger log = LoggerFactory.getLogger(RidGeneratorUtil.class); - private static final String MD5_ALGORITHM = "MD5"; - - public static String generate(Method method) { - MessageDigest messageDigest; - try { - messageDigest = MessageDigest.getInstance(MD5_ALGORITHM); - } catch (NoSuchAlgorithmException e) { - log.debug("Failed to implement MessageDigest algorithm: " + MD5_ALGORITHM); - throw new ArtifactManagementException("No such algorithm for MessageDigest: " - + MD5_ALGORITHM); - } - String identifier = method.getDeclaringClass().getCanonicalName() + "." + method.getName(); - byte[] hash = messageDigest.digest(identifier.getBytes(StandardCharsets.UTF_8)); - - StringBuilder sb = new StringBuilder(); - for (byte b : hash) { - sb.append(String.format("%02x", b)); - } - - return method.getName() + ":" + sb; - } -} diff --git a/java-reporter-junit/src/main/java/io/testomat/junit/listener/JunitListener.java b/java-reporter-junit/src/main/java/io/testomat/junit/listener/JunitListener.java index d11d384..b50686c 100644 --- a/java-reporter-junit/src/main/java/io/testomat/junit/listener/JunitListener.java +++ b/java-reporter-junit/src/main/java/io/testomat/junit/listener/JunitListener.java @@ -13,6 +13,7 @@ import java.util.Set; import java.util.concurrent.ConcurrentHashMap; import org.junit.jupiter.api.extension.AfterAllCallback; +import org.junit.jupiter.api.extension.AfterEachCallback; import org.junit.jupiter.api.extension.BeforeAllCallback; import org.junit.jupiter.api.extension.BeforeEachCallback; import org.junit.jupiter.api.extension.ExtensionContext; @@ -25,7 +26,7 @@ * Reports JUnit test execution results to Testomat.io platform. */ public class JunitListener implements BeforeEachCallback, BeforeAllCallback, - AfterAllCallback, TestWatcher { + AfterAllCallback, AfterEachCallback, TestWatcher { private static final Logger log = LoggerFactory.getLogger(JunitListener.class); private static final String LISTENING_REQUIRED_PROPERTY_NAME = "testomatio.listening"; @@ -152,4 +153,12 @@ private boolean isListeningRequired() { return false; } } + + @Override + public void afterEach(ExtensionContext extensionContext) throws Exception { + ArtifactHandler.Directories.get(0) + + + DIRECTORIES.clear + } } From 1c513d68d6b199b8edb61714dc0d72c4bf006e60 Mon Sep 17 00:00:00 2001 From: Yevhenii Vlasenko Date: Sat, 13 Sep 2025 04:28:46 +0300 Subject: [PATCH 07/44] checkpoint --- java-reporter-core/pom.xml | 14 +- .../core/artifact/ArtifactKeyGenerator.java | 20 + .../core/artifact/ArtifactManager.java | 2 +- .../core/artifact/client/AwsService.java | 128 ++ .../credential/CredentialsManager.java | 79 + .../CredentialsValidationService.java | 57 + .../artifact/credential/S3Credentials.java | 76 + .../testomat/core/client/NativeApiClient.java | 16 +- .../core/constants/CredentialConstants.java | 14 + .../io/testomat/core/facade/Testomatio.java | 2 +- java-reporter-junit/pom.xml | 8 +- .../junit/listener/JunitListener.java | 19 +- .../junit/listener/JunitListenerTest.java | 1278 ++++++++--------- 13 files changed, 1055 insertions(+), 658 deletions(-) create mode 100644 java-reporter-core/src/main/java/io/testomat/core/artifact/ArtifactKeyGenerator.java create mode 100644 java-reporter-core/src/main/java/io/testomat/core/artifact/client/AwsService.java create mode 100644 java-reporter-core/src/main/java/io/testomat/core/artifact/credential/CredentialsManager.java create mode 100644 java-reporter-core/src/main/java/io/testomat/core/artifact/credential/CredentialsValidationService.java create mode 100644 java-reporter-core/src/main/java/io/testomat/core/artifact/credential/S3Credentials.java create mode 100644 java-reporter-core/src/main/java/io/testomat/core/constants/CredentialConstants.java diff --git a/java-reporter-core/pom.xml b/java-reporter-core/pom.xml index e349833..9e829c7 100644 --- a/java-reporter-core/pom.xml +++ b/java-reporter-core/pom.xml @@ -5,9 +5,9 @@ http://maven.apache.org/xsd/maven-4.0.0.xsd"> 4.0.0 - io.testomat + coretest java-reporter-core - 0.6.8 + coretest jar Testomat.io Reporter Core @@ -67,6 +67,16 @@ jackson-databind ${jackson.version} + + + + + + + software.amazon.awssdk + aws-sdk-java + 2.33.6 + diff --git a/java-reporter-core/src/main/java/io/testomat/core/artifact/ArtifactKeyGenerator.java b/java-reporter-core/src/main/java/io/testomat/core/artifact/ArtifactKeyGenerator.java new file mode 100644 index 0000000..548c829 --- /dev/null +++ b/java-reporter-core/src/main/java/io/testomat/core/artifact/ArtifactKeyGenerator.java @@ -0,0 +1,20 @@ +package io.testomat.core.artifact; + +import java.nio.file.Paths; + +public class ArtifactKeyGenerator { + private static String runId; + private static final String SEPARATOR = "/"; + + public static void initializeRunId(Object runId) { + ArtifactKeyGenerator.runId = runId.toString(); + } + + public String generateKey(String dir, String rid, String testName) { + return runId + + SEPARATOR + + testName + ":" + rid + + SEPARATOR + + Paths.get(dir).getFileName(); + } +} diff --git a/java-reporter-core/src/main/java/io/testomat/core/artifact/ArtifactManager.java b/java-reporter-core/src/main/java/io/testomat/core/artifact/ArtifactManager.java index 9c49760..c1fe801 100644 --- a/java-reporter-core/src/main/java/io/testomat/core/artifact/ArtifactManager.java +++ b/java-reporter-core/src/main/java/io/testomat/core/artifact/ArtifactManager.java @@ -11,7 +11,7 @@ public class ArtifactManager { private static final Logger log = LoggerFactory.getLogger(ArtifactManager.class); - public void manageArtifact(String... directories) { + public void storeDirectories(String... directories) { for (String dir : directories) { if (isValidFilePath(dir)) { TemporalArtifactStorage.store(dir); diff --git a/java-reporter-core/src/main/java/io/testomat/core/artifact/client/AwsService.java b/java-reporter-core/src/main/java/io/testomat/core/artifact/client/AwsService.java new file mode 100644 index 0000000..dc887a9 --- /dev/null +++ b/java-reporter-core/src/main/java/io/testomat/core/artifact/client/AwsService.java @@ -0,0 +1,128 @@ +package io.testomat.core.artifact.client; + +import io.testomat.core.artifact.ArtifactKeyGenerator; +import io.testomat.core.artifact.TemporalArtifactStorage; +import io.testomat.core.artifact.credential.CredentialsManager; +import io.testomat.core.artifact.credential.CredentialsValidationService; +import io.testomat.core.artifact.credential.S3Credentials; +import io.testomat.core.exception.ArtifactManagementException; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.stream.Collectors; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import software.amazon.awssdk.auth.credentials.AwsBasicCredentials; +import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider; +import software.amazon.awssdk.core.async.AsyncRequestBody; +import software.amazon.awssdk.regions.Region; +import software.amazon.awssdk.services.s3.S3AsyncClient; +import software.amazon.awssdk.services.s3.model.PutObjectRequest; + +public class AwsService { + private static final Logger log = LoggerFactory.getLogger(AwsService.class); + private final CredentialsValidationService validationService; + private final ArtifactKeyGenerator artifactKeyGenerator; + + public AwsService() { + this.artifactKeyGenerator = new ArtifactKeyGenerator(); + this.validationService = new CredentialsValidationService(); + log.debug("AWS Async Client initialized"); + } + + private volatile S3AsyncClient s3AsyncClient; + + public void uploadAllArtifactsForTest(String testName, String rid) { + if (TemporalArtifactStorage.DIRECTORIES.get().isEmpty()) { + log.debug("Artifact list is empty for test: {}", testName); + CompletableFuture.completedFuture(null); + return; + } + + S3Credentials credentials = CredentialsManager.getCredentials(); + + List> uploadFutures = TemporalArtifactStorage.DIRECTORIES.get().stream() + .map(dir -> { + String key = artifactKeyGenerator.generateKey(dir, rid, testName); + return uploadArtifact(dir, key, credentials); + }) + .collect(Collectors.toList()); + + CompletableFuture.allOf(uploadFutures.toArray(new CompletableFuture[0])); + } + + private CompletableFuture uploadArtifact(String dir, String key, S3Credentials credentials) { + byte[] content; + Path path = Paths.get(dir); + try { + content = Files.readAllBytes(path); + log.debug("Successfully read {} bytes from file: {}", content.length, path); + } catch (IOException e) { + log.error("Failed to read bytes from path: {}", path, e); + CompletableFuture failedFuture = new CompletableFuture<>(); + failedFuture.completeExceptionally(new ArtifactManagementException("Failed to read bytes from path: " + path)); + return failedFuture; + } + + PutObjectRequest request = PutObjectRequest.builder() + .bucket(credentials.getBucket()) + .key(key) + .build(); + + log.debug("Uploading to S3: bucket={}, key={}, size={} bytes", + credentials.getBucket(), key, content.length); + + return getOrCreateClient().putObject(request, AsyncRequestBody.fromBytes(content)) + .thenAccept(response -> { + log.info("S3 upload completed successfully for file: {} (ETag: {})", path, response.eTag()); + }) + .exceptionally(throwable -> { + log.error("S3 upload failed for file: {} to bucket: {}, key: {}", path, credentials.getBucket(), key, throwable); + throw new ArtifactManagementException("S3 upload failed: " + throwable.getMessage(), throwable); + }); + } + + private S3AsyncClient initialize() { + log.debug("Initializing S3 Client"); + S3Credentials s3Credentials = CredentialsManager.getCredentials(); + + if (!validationService.areCredentialsValid(s3Credentials)) { + log.error("S3 credentials validation failed during client initialization"); + throw new ArtifactManagementException("Invalid S3 credentials provided"); + } + + log.debug("Creating S3 client for region: {}, bucket: {}", + s3Credentials.getRegion(), s3Credentials.getBucket()); + + AwsBasicCredentials basicCredentials = AwsBasicCredentials.create( + s3Credentials.getAccessKeyId(), + s3Credentials.getSecretAccessKey()); + + StaticCredentialsProvider staticCredentialsProvider = StaticCredentialsProvider.create(basicCredentials); + S3AsyncClient client = S3AsyncClient.builder() + .credentialsProvider(staticCredentialsProvider) + .multipartEnabled(true) + .region(Region.of(s3Credentials.getRegion())) + .build(); + + log.info("S3 Async Client initialized successfully for region: {}", s3Credentials.getRegion()); + return client; + } + + private S3AsyncClient getOrCreateClient() { + S3AsyncClient client = s3AsyncClient; + if (client == null) { + synchronized (this) { + client = s3AsyncClient; + if (client == null) { + log.debug("Lazy initializing S3 Async Client"); + s3AsyncClient = client = initialize(); + } + } + } + return client; + } +} diff --git a/java-reporter-core/src/main/java/io/testomat/core/artifact/credential/CredentialsManager.java b/java-reporter-core/src/main/java/io/testomat/core/artifact/credential/CredentialsManager.java new file mode 100644 index 0000000..bfb0f4e --- /dev/null +++ b/java-reporter-core/src/main/java/io/testomat/core/artifact/credential/CredentialsManager.java @@ -0,0 +1,79 @@ +package io.testomat.core.artifact.credential; + +import static io.testomat.core.constants.CredentialConstants.ACCESS_KEY_PROPERTY_NAME; +import static io.testomat.core.constants.CredentialConstants.BUCKET_PROPERTY_NAME; +import static io.testomat.core.constants.CredentialConstants.IAM_PROPERTY_NAME; +import static io.testomat.core.constants.CredentialConstants.PRESIGN_PROPERTY_NAME; +import static io.testomat.core.constants.CredentialConstants.REGION_PROPERTY_NAME; +import static io.testomat.core.constants.CredentialConstants.SECRET_KEY_PROPERTY_NAME; +import static io.testomat.core.constants.CredentialConstants.SHARED_PROPERTY_NAME; + +import java.util.Map; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public class CredentialsManager { + private static final Logger log = LoggerFactory.getLogger(CredentialsManager.class); + private static final S3Credentials credentials = new S3Credentials(); + + public static S3Credentials getCredentials() { + return credentials; + } + + public void populateCredentialsFromServerResponse(Map credsFromServer) { + log.debug("Populating S3 credentials from configuration map"); + + if (credsFromServer == null || credsFromServer.isEmpty()) { + log.warn("Received null or empty credentials map"); + return; + } + + credentials.setPresign(getBoolean(credsFromServer, PRESIGN_PROPERTY_NAME, false)); + credentials.setShared(getBoolean(credsFromServer, SHARED_PROPERTY_NAME, false)); + credentials.setIam(getBoolean(credsFromServer, IAM_PROPERTY_NAME, false)); + credentials.setSecretAccessKey(getString(credsFromServer, SECRET_KEY_PROPERTY_NAME)); + credentials.setAccessKeyId(getString(credsFromServer, ACCESS_KEY_PROPERTY_NAME)); + credentials.setBucket(getString(credsFromServer, BUCKET_PROPERTY_NAME)); + credentials.setRegion(getString(credsFromServer, REGION_PROPERTY_NAME)); + + log.info("S3 credentials populated: bucket={}, region={}, presign={}, shared={}, iam={}", + credentials.getBucket(), credentials.getRegion(), + credentials.isPresign(), credentials.isShared(), credentials.isIam()); + + if (!areCredentialsAvailable()) { + log.error("Credentials population completed but essential fields are missing"); + } else { + log.debug("All required S3 credentials are available"); + } + } + + public void populateCredentialsFromEnvironment() { + + } + + private boolean areCredentialsAvailable() { + boolean accessKeyAvailable = credentials.getAccessKeyId() != null; + boolean secretKeyAvailable = credentials.getSecretAccessKey() != null; + boolean bucketAvailable = credentials.getBucket() != null; + boolean regionAvailable = credentials.getRegion() != null; + + boolean allAvailable = accessKeyAvailable && secretKeyAvailable && bucketAvailable && regionAvailable; + + if (!allAvailable) { + log.warn("Missing S3 credentials - accessKey: {}, secretKey: {}, bucket: {}, region: {}", + accessKeyAvailable, secretKeyAvailable, bucketAvailable, regionAvailable); + } + + return allAvailable; + } + + private boolean getBoolean(Map map, String key, boolean defaultValue) { + Object value = map.get(key); + return value != null ? Boolean.parseBoolean(value.toString()) : defaultValue; + } + + private String getString(Map map, String key) { + Object value = map.get(key); + return value != null ? value.toString() : null; + } +} diff --git a/java-reporter-core/src/main/java/io/testomat/core/artifact/credential/CredentialsValidationService.java b/java-reporter-core/src/main/java/io/testomat/core/artifact/credential/CredentialsValidationService.java new file mode 100644 index 0000000..c1388f9 --- /dev/null +++ b/java-reporter-core/src/main/java/io/testomat/core/artifact/credential/CredentialsValidationService.java @@ -0,0 +1,57 @@ +package io.testomat.core.artifact.credential; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import software.amazon.awssdk.auth.credentials.AwsBasicCredentials; +import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider; +import software.amazon.awssdk.regions.Region; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.model.HeadBucketRequest; +import software.amazon.awssdk.services.s3.model.S3Exception; + +public class CredentialsValidationService { + private static final Logger log = LoggerFactory.getLogger(CredentialsValidationService.class); + + public boolean areCredentialsValid(S3Credentials creds) { + if (creds == null) { + log.error("Cannot validate null S3 credentials"); + return false; + } + + if (creds.getAccessKeyId() == null || creds.getSecretAccessKey() == null || + creds.getRegion() == null || creds.getBucket() == null) { + log.error("S3 credentials validation failed: missing required fields - " + + "accessKey: {}, secretKey: {}, region: {}, bucket: {}", + creds.getAccessKeyId() != null, creds.getSecretAccessKey() != null, + creds.getRegion() != null, creds.getBucket() != null); + return false; + } + + log.debug("Validating S3 credentials for bucket: {} in region: {}", + creds.getBucket(), creds.getRegion()); + + try (S3Client client = S3Client.builder() + .credentialsProvider(StaticCredentialsProvider.create( + AwsBasicCredentials.create(creds.getAccessKeyId(), creds.getSecretAccessKey()))) + .region(Region.of(creds.getRegion())) + .build()) { + + HeadBucketRequest headBucketRequest = HeadBucketRequest.builder() + .bucket(creds.getBucket()) + .build(); + + client.headBucket(headBucketRequest); + log.info("S3 credentials validation successful for bucket: {} in region: {}", + creds.getBucket(), creds.getRegion()); + return true; + } catch (S3Exception e) { + log.error("S3 credentials validation failed for bucket: {} - {} (Status: {})", + creds.getBucket(), e.awsErrorDetails().errorMessage(), e.statusCode()); + return false; + } catch (Exception e) { + log.error("S3 connection error during validation for bucket: {} - {}", + creds.getBucket(), e.getMessage(), e); + return false; + } + } +} diff --git a/java-reporter-core/src/main/java/io/testomat/core/artifact/credential/S3Credentials.java b/java-reporter-core/src/main/java/io/testomat/core/artifact/credential/S3Credentials.java new file mode 100644 index 0000000..eddc940 --- /dev/null +++ b/java-reporter-core/src/main/java/io/testomat/core/artifact/credential/S3Credentials.java @@ -0,0 +1,76 @@ +package io.testomat.core.artifact.credential; + +public class S3Credentials { + private boolean presign; + private boolean shared; + private boolean iam; + private String secretAccessKey; + private String accessKeyId; + private String bucket; + private String region; + private String customEndpoint; + + public boolean isPresign() { + return presign; + } + + public void setPresign(boolean presign) { + this.presign = presign; + } + + public boolean isShared() { + return shared; + } + + public void setShared(boolean shared) { + this.shared = shared; + } + + public boolean isIam() { + return iam; + } + + public void setIam(boolean iam) { + this.iam = iam; + } + + public String getSecretAccessKey() { + return secretAccessKey; + } + + public void setSecretAccessKey(String secretAccessKey) { + this.secretAccessKey = secretAccessKey; + } + + public String getAccessKeyId() { + return accessKeyId; + } + + public void setAccessKeyId(String accessKeyId) { + this.accessKeyId = accessKeyId; + } + + public String getBucket() { + return bucket; + } + + public void setBucket(String bucket) { + this.bucket = bucket; + } + + public String getRegion() { + return region; + } + + public void setRegion(String region) { + this.region = region; + } + + public String getCustomEndpoint() { + return customEndpoint; + } + + public void setCustomEndpoint(String customEndpoint) { + this.customEndpoint = customEndpoint; + } + } \ No newline at end of file diff --git a/java-reporter-core/src/main/java/io/testomat/core/client/NativeApiClient.java b/java-reporter-core/src/main/java/io/testomat/core/client/NativeApiClient.java index bf31c6f..abf1e25 100644 --- a/java-reporter-core/src/main/java/io/testomat/core/client/NativeApiClient.java +++ b/java-reporter-core/src/main/java/io/testomat/core/client/NativeApiClient.java @@ -1,5 +1,7 @@ package io.testomat.core.client; +import io.testomat.core.artifact.ArtifactKeyGenerator; +import io.testomat.core.artifact.credential.CredentialsManager; import io.testomat.core.client.http.CustomHttpClient; import io.testomat.core.client.request.NativeRequestBodyBuilder; import io.testomat.core.client.request.RequestBodyBuilder; @@ -31,6 +33,7 @@ public class NativeApiClient implements ApiInterface { private final String apiKey; private final CustomHttpClient client; private final RequestBodyBuilder requestBodyBuilder; + private final CredentialsManager credentialsManager = new CredentialsManager(); /** * Creates API client with injectable dependencies. @@ -56,16 +59,21 @@ public String createRun(String title) throws IOException { log.debug("Creating run with request url: {}", url); String requestBody = requestBodyBuilder.buildCreateRunBody(title); - Map responseBody = client.post(url, requestBody, Map.class); + Map responseBody = client.post(url, requestBody, Map.class); log.debug(responseBody.toString()); if (responseBody == null || !responseBody.containsKey(RESPONSE_UID_KEY)) { throw new RunCreationFailedException( "Invalid response: missing UID in create test run response"); } + if (responseBody.containsKey("artifacts")) { + ArtifactKeyGenerator.initializeRunId(responseBody.get(RESPONSE_UID_KEY)); + Map creds = (Map)responseBody.get("artifacts"); + credentialsManager.populateCredentialsFromServerResponse(creds); + } logAndPrintUrls(responseBody); log.debug("Created test run with UID: {}", responseBody.get(RESPONSE_UID_KEY)); - return responseBody.get(RESPONSE_UID_KEY); + return responseBody.get(RESPONSE_UID_KEY).toString(); } @Override @@ -118,8 +126,8 @@ public void finishTestRun(String uid, float duration) { } } - private void logAndPrintUrls(Map responseBody) { - String publicUrl = responseBody.get("public_url"); + private void logAndPrintUrls(Map responseBody) { + String publicUrl = responseBody.get("public_url").toString(); log.info("[TESTOMATIO] Testomat.io java core reporter version: [{}]", REPORTER_VERSION); diff --git a/java-reporter-core/src/main/java/io/testomat/core/constants/CredentialConstants.java b/java-reporter-core/src/main/java/io/testomat/core/constants/CredentialConstants.java new file mode 100644 index 0000000..662323b --- /dev/null +++ b/java-reporter-core/src/main/java/io/testomat/core/constants/CredentialConstants.java @@ -0,0 +1,14 @@ +package io.testomat.core.constants; + +public class CredentialConstants { + public static final String PRESIGN_PROPERTY_NAME = "presign"; + public static final String SHARED_PROPERTY_NAME = "shared"; + public static final String IAM_PROPERTY_NAME = "iam"; + public static final String SECRET_KEY_PROPERTY_NAME = "SECRET_ACCESS_KEY"; + public static final String ACCESS_KEY_PROPERTY_NAME = "ACCESS_KEY_ID"; + public static final String BUCKET_PROPERTY_NAME = "BUCKET"; + public static final String REGION_PROPERTY_NAME = "REGION"; + + private CredentialConstants() { + } +} diff --git a/java-reporter-core/src/main/java/io/testomat/core/facade/Testomatio.java b/java-reporter-core/src/main/java/io/testomat/core/facade/Testomatio.java index a565efb..d598a56 100644 --- a/java-reporter-core/src/main/java/io/testomat/core/facade/Testomatio.java +++ b/java-reporter-core/src/main/java/io/testomat/core/facade/Testomatio.java @@ -8,6 +8,6 @@ public class Testomatio { private static final ConcurrentHashMap> ARTIFACT_STORAGE = new ConcurrentHashMap<>(); public static void artifact(String... directories) { - ServiceRegistryUtil.getService(ArtifactManager.class).manageArtifact(directories); + ServiceRegistryUtil.getService(ArtifactManager.class).storeDirectories(directories); } } diff --git a/java-reporter-junit/pom.xml b/java-reporter-junit/pom.xml index 9a6fb85..8e1a1e4 100644 --- a/java-reporter-junit/pom.xml +++ b/java-reporter-junit/pom.xml @@ -4,9 +4,9 @@ xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> 4.0.0 - io.testomat + junittest java-reporter-junit - 0.7.5 + junittest jar Testomat.io Java Reporter JUnit @@ -47,9 +47,9 @@ - io.testomat + coretest java-reporter-core - 0.7.5 + coretest org.junit.jupiter diff --git a/java-reporter-junit/src/main/java/io/testomat/junit/listener/JunitListener.java b/java-reporter-junit/src/main/java/io/testomat/junit/listener/JunitListener.java index b50686c..d6b2e13 100644 --- a/java-reporter-junit/src/main/java/io/testomat/junit/listener/JunitListener.java +++ b/java-reporter-junit/src/main/java/io/testomat/junit/listener/JunitListener.java @@ -4,6 +4,7 @@ import static io.testomat.core.constants.CommonConstants.PASSED; import static io.testomat.core.constants.CommonConstants.SKIPPED; +import io.testomat.core.artifact.client.AwsService; import io.testomat.core.propertyconfig.impl.PropertyProviderFactoryImpl; import io.testomat.core.propertyconfig.interf.PropertyProvider; import io.testomat.core.runmanager.GlobalRunManager; @@ -35,7 +36,7 @@ public class JunitListener implements BeforeEachCallback, BeforeAllCallback, private final GlobalRunManager runManager; private final JunitTestReporter reporter; private final PropertyProvider provider; - + private final AwsService awsService; private final Set processedClasses; public JunitListener() { @@ -43,6 +44,7 @@ public JunitListener() { this.runManager = GlobalRunManager.getInstance(); this.reporter = new JunitTestReporter(); this.processedClasses = ConcurrentHashMap.newKeySet(); + this.awsService = new AwsService(); this.provider = PropertyProviderFactoryImpl.getPropertyProviderFactory().getPropertyProvider(); } @@ -58,12 +60,14 @@ public JunitListener() { public JunitListener(MethodExportManager methodExportManager, GlobalRunManager runManager, JunitTestReporter reporter, - PropertyProvider provider) { + PropertyProvider provider, + AwsService awsService) { this.methodExportManager = methodExportManager; this.runManager = runManager; this.reporter = reporter; this.provider = provider; this.processedClasses = ConcurrentHashMap.newKeySet(); + this.awsService = awsService; } @Override @@ -155,10 +159,11 @@ private boolean isListeningRequired() { } @Override - public void afterEach(ExtensionContext extensionContext) throws Exception { - ArtifactHandler.Directories.get(0) - - - DIRECTORIES.clear + public void afterEach(ExtensionContext context) throws Exception { + awsService.uploadAllArtifactsForTest(context.getDisplayName(), context.getUniqueId()); + // ArtifactHandler.Directories.get(0) + // + // + // DIRECTORIES.clear } } diff --git a/java-reporter-junit/src/test/java/io/testomat/junit/listener/JunitListenerTest.java b/java-reporter-junit/src/test/java/io/testomat/junit/listener/JunitListenerTest.java index dc139e0..4d85ac0 100644 --- a/java-reporter-junit/src/test/java/io/testomat/junit/listener/JunitListenerTest.java +++ b/java-reporter-junit/src/test/java/io/testomat/junit/listener/JunitListenerTest.java @@ -1,639 +1,639 @@ -package io.testomat.junit.listener; - -import static io.testomat.core.constants.CommonConstants.FAILED; -import static io.testomat.core.constants.CommonConstants.PASSED; -import static io.testomat.core.constants.CommonConstants.SKIPPED; -import static org.junit.jupiter.api.Assertions.assertNotNull; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyString; -import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.Mockito.never; -import static org.mockito.Mockito.times; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; - -import io.testomat.core.exception.PropertyNotFoundException; -import io.testomat.core.propertyconfig.interf.PropertyProvider; -import io.testomat.core.runmanager.GlobalRunManager; -import io.testomat.junit.methodexporter.MethodExportManager; -import io.testomat.junit.reporter.JunitTestReporter; -import java.util.Optional; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Nested; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtensionContext; -import org.mockito.Mock; -import org.mockito.MockitoAnnotations; - -@DisplayName("JunitListener Tests (Updated Version)") -class JunitListenerTest { - - @Mock - private MethodExportManager mockMethodExportManager; - - @Mock - private GlobalRunManager mockRunManager; - - @Mock - private JunitTestReporter mockReporter; - - @Mock - private PropertyProvider mockPropertyProvider; - - @Mock - private ExtensionContext mockExtensionContext; - - private JunitListener junitListener; - - // Test classes for testing different scenarios - public static class TestClassA { - } - - public static class TestClassB { - } - - @BeforeEach - void setUp() { - MockitoAnnotations.openMocks(this); - - // Setup mock context with a valid unique ID to avoid NPE in cleanup methods - when(mockExtensionContext.getUniqueId()).thenReturn("test-unique-id-123"); - - junitListener = new JunitListener( - mockMethodExportManager, - mockRunManager, - mockReporter, - mockPropertyProvider - ); - } - - @Nested - @DisplayName("Constructor Tests") - class ConstructorTests { - - @Test - @DisplayName("Should create instance with default constructor") - void shouldCreateInstanceWithDefaultConstructor() { - // When - JunitListener listener = new JunitListener(); - - // Then - assertNotNull(listener); - } - - @Test - @DisplayName("Should create instance with parameterized constructor") - void shouldCreateInstanceWithParameterizedConstructor() { - // When - JunitListener listener = new JunitListener( - mockMethodExportManager, - mockRunManager, - mockReporter, - mockPropertyProvider - ); - - // Then - assertNotNull(listener); - } - } - - @Nested - @DisplayName("Listening Required Logic Tests") - class ListeningRequiredLogicTests { - - @Test - @DisplayName("Should return false when property throws PropertyNotFoundException") - void shouldReturnFalseWhenPropertyThrowsPropertyNotFoundException() { - // Given - when(mockPropertyProvider.getProperty("testomatio.listening")) - .thenThrow(new PropertyNotFoundException("Property not found")); - - // When - junitListener.beforeAll(mockExtensionContext); - - // Then - should do early return, not call runManager - verify(mockRunManager, never()).incrementSuiteCounter(); - } - - @Test - @DisplayName("Should return false when property throws RuntimeException") - void shouldReturnFalseWhenPropertyThrowsRuntimeException() { - // Given - when(mockPropertyProvider.getProperty("testomatio.listening")) - .thenThrow(new RuntimeException("Unexpected error")); - - // When - junitListener.beforeAll(mockExtensionContext); - - // Then - should do early return, not call runManager - verify(mockRunManager, never()).incrementSuiteCounter(); - } - - @Test - @DisplayName("Should return false when property returns null") - void shouldReturnFalseWhenPropertyReturnsNull() { - // Given - when(mockPropertyProvider.getProperty("testomatio.listening")).thenReturn(null); - - // When - junitListener.beforeAll(mockExtensionContext); - - // Then - should do early return, not call runManager - verify(mockRunManager, never()).incrementSuiteCounter(); - } - } - - @Nested - @DisplayName("BeforeAll Tests") - class BeforeAllTests { - - @Test - @DisplayName("Should increment suite counter when listening is required") - void shouldIncrementSuiteCounterWhenListeningIsRequired() { - // Given - when(mockPropertyProvider.getProperty("testomatio.listening")).thenReturn("true"); - - // When - junitListener.beforeAll(mockExtensionContext); - - // Then - verify(mockRunManager, times(1)).incrementSuiteCounter(); - } - - @Test - @DisplayName("Should not increment suite counter when listening is not required") - void shouldNotIncrementSuiteCounterWhenListeningIsNotRequired() { - // Given - when(mockPropertyProvider.getProperty("testomatio.listening")).thenReturn(null); - - // When - junitListener.beforeAll(mockExtensionContext); - - // Then - verify(mockRunManager, never()).incrementSuiteCounter(); - } - - @Test - @DisplayName("Should handle multiple beforeAll calls when listening is required") - void shouldHandleMultipleBeforeAllCallsWhenListeningIsRequired() { - // Given - when(mockPropertyProvider.getProperty("testomatio.listening")).thenReturn("enabled"); - - // When - junitListener.beforeAll(mockExtensionContext); - junitListener.beforeAll(mockExtensionContext); - junitListener.beforeAll(mockExtensionContext); - - // Then - verify(mockRunManager, times(3)).incrementSuiteCounter(); - } - } - - @Nested - @DisplayName("AfterAll Tests") - class AfterAllTests { - - @Test - @DisplayName("Should export test class and decrement suite counter when listening is required") - void shouldExportTestClassAndDecrementSuiteCounterWhenListeningIsRequired() { - // Given - when(mockPropertyProvider.getProperty("testomatio.listening")).thenReturn("true"); - when(mockExtensionContext.getTestClass()).thenReturn(Optional.of(TestClassA.class)); - - // When - junitListener.afterAll(mockExtensionContext); - - // Then - verify(mockMethodExportManager, times(1)).loadTestBodyForClass(TestClassA.class); - verify(mockRunManager, times(1)).decrementSuiteCounter(); - } - - @Test - @DisplayName("Should not export or decrement when listening is not required") - void shouldNotExportOrDecrementWhenListeningIsNotRequired() { - // Given - when(mockPropertyProvider.getProperty("testomatio.listening")).thenReturn(null); - - // When - junitListener.afterAll(mockExtensionContext); - - // Then - verify(mockMethodExportManager, never()).loadTestBodyForClass(any()); - verify(mockRunManager, never()).decrementSuiteCounter(); - } - - @Test - @DisplayName("Should only decrement suite counter when no test class present") - void shouldOnlyDecrementSuiteCounterWhenNoTestClassPresent() { - // Given - when(mockPropertyProvider.getProperty("testomatio.listening")).thenReturn("true"); - when(mockExtensionContext.getTestClass()).thenReturn(Optional.empty()); - - // When - junitListener.afterAll(mockExtensionContext); - - // Then - verify(mockMethodExportManager, never()).loadTestBodyForClass(any()); - verify(mockRunManager, times(1)).decrementSuiteCounter(); - } - } - - @Nested - @DisplayName("BeforeEach Tests") - class BeforeEachTests { - - @Test - @DisplayName("Should not perform any operations in beforeEach regardless of listening status") - void shouldNotPerformAnyOperationsInBeforeEach() { - // Given - when(mockPropertyProvider.getProperty("testomatio.listening")).thenReturn("true"); - - // When - junitListener.beforeEach(mockExtensionContext); - - // Then - verify(mockMethodExportManager, never()).loadTestBodyForClass(any()); - verify(mockRunManager, never()).incrementSuiteCounter(); - verify(mockRunManager, never()).decrementSuiteCounter(); - verify(mockReporter, never()).reportTestResult(any(), anyString(), anyString()); - } - } - - @Nested - @DisplayName("Test Disabled Tests") - class TestDisabledTests { - - @Test - @DisplayName("Should report test as disabled when listening is required") - void shouldReportTestAsDisabledWhenListeningIsRequired() { - // Given - String reason = "Test disabled for maintenance"; - when(mockPropertyProvider.getProperty("testomatio.listening")).thenReturn("true"); - when(mockExtensionContext.getTestClass()).thenReturn(Optional.of(TestClassA.class)); - - // When - junitListener.testDisabled(mockExtensionContext, Optional.of(reason)); - - // Then - verify(mockReporter, times(1)).reportTestResult(mockExtensionContext, SKIPPED, reason); - verify(mockMethodExportManager, times(1)).loadTestBodyForClass(TestClassA.class); - } - - @Test - @DisplayName("Should not report when listening is not required") - void shouldNotReportWhenListeningIsNotRequired() { - // Given - String reason = "Test disabled"; - when(mockPropertyProvider.getProperty("testomatio.listening")).thenReturn(null); - - // When - junitListener.testDisabled(mockExtensionContext, Optional.of(reason)); - - // Then - verify(mockReporter, never()).reportTestResult(any(), anyString(), anyString()); - verify(mockMethodExportManager, never()).loadTestBodyForClass(any()); - } - - @Test - @DisplayName("Should use default message when no reason provided") - void shouldUseDefaultMessageWhenNoReasonProvided() { - // Given - when(mockPropertyProvider.getProperty("testomatio.listening")).thenReturn("true"); - when(mockExtensionContext.getTestClass()).thenReturn(Optional.of(TestClassA.class)); - - // When - junitListener.testDisabled(mockExtensionContext, Optional.empty()); - - // Then - verify(mockReporter, times(1)).reportTestResult(mockExtensionContext, SKIPPED, "Test disabled"); - verify(mockMethodExportManager, times(1)).loadTestBodyForClass(TestClassA.class); - } - } - - @Nested - @DisplayName("Test Successful Tests") - class TestSuccessfulTests { - - @Test - @DisplayName("Should report test as successful when listening is required") - void shouldReportTestAsSuccessfulWhenListeningIsRequired() { - // Given - when(mockPropertyProvider.getProperty("testomatio.listening")).thenReturn("true"); - when(mockExtensionContext.getTestClass()).thenReturn(Optional.of(TestClassA.class)); - - // When - junitListener.testSuccessful(mockExtensionContext); - - // Then - verify(mockReporter, times(1)).reportTestResult(mockExtensionContext, PASSED, null); - verify(mockMethodExportManager, times(1)).loadTestBodyForClass(TestClassA.class); - } - - @Test - @DisplayName("Should not report when listening is not required") - void shouldNotReportWhenListeningIsNotRequired() { - // Given - when(mockPropertyProvider.getProperty("testomatio.listening")).thenReturn(null); - - // When - junitListener.testSuccessful(mockExtensionContext); - - // Then - verify(mockReporter, never()).reportTestResult(any(), anyString(), anyString()); - verify(mockMethodExportManager, never()).loadTestBodyForClass(any()); - } - } - - @Nested - @DisplayName("Test Aborted Tests") - class TestAbortedTests { - - @Test - @DisplayName("Should report test as aborted when listening is required") - void shouldReportTestAsAbortedWhenListeningIsRequired() { - // Given - Throwable cause = new RuntimeException("Test was aborted"); - when(mockPropertyProvider.getProperty("testomatio.listening")).thenReturn("true"); - when(mockExtensionContext.getTestClass()).thenReturn(Optional.of(TestClassA.class)); - - // When - junitListener.testAborted(mockExtensionContext, cause); - - // Then - verify(mockReporter, times(1)).reportTestResult(mockExtensionContext, SKIPPED, "Test was aborted"); - verify(mockMethodExportManager, times(1)).loadTestBodyForClass(TestClassA.class); - } - - @Test - @DisplayName("Should not report when listening is not required") - void shouldNotReportWhenListeningIsNotRequired() { - // Given - Throwable cause = new RuntimeException("Test was aborted"); - when(mockPropertyProvider.getProperty("testomatio.listening")).thenReturn(null); - - // When - junitListener.testAborted(mockExtensionContext, cause); - - // Then - verify(mockReporter, never()).reportTestResult(any(), anyString(), anyString()); - verify(mockMethodExportManager, never()).loadTestBodyForClass(any()); - } - - @Test - @DisplayName("Should handle null cause message") - void shouldHandleNullCauseMessage() { - // Given - Throwable cause = new RuntimeException(); // No message - when(mockPropertyProvider.getProperty("testomatio.listening")).thenReturn("true"); - when(mockExtensionContext.getTestClass()).thenReturn(Optional.of(TestClassA.class)); - - // When - junitListener.testAborted(mockExtensionContext, cause); - - // Then - verify(mockReporter, times(1)).reportTestResult(mockExtensionContext, SKIPPED, null); - verify(mockMethodExportManager, times(1)).loadTestBodyForClass(TestClassA.class); - } - } - - @Nested - @DisplayName("Test Failed Tests") - class TestFailedTests { - - @Test - @DisplayName("Should report test as failed when listening is required") - void shouldReportTestAsFailedWhenListeningIsRequired() { - // Given - Throwable cause = new AssertionError("Expected <5> but was <3>"); - when(mockPropertyProvider.getProperty("testomatio.listening")).thenReturn("true"); - when(mockExtensionContext.getTestClass()).thenReturn(Optional.of(TestClassA.class)); - - // When - junitListener.testFailed(mockExtensionContext, cause); - - // Then - verify(mockReporter, times(1)).reportTestResult(mockExtensionContext, FAILED, "Expected <5> but was <3>"); - verify(mockMethodExportManager, times(1)).loadTestBodyForClass(TestClassA.class); - } - - @Test - @DisplayName("Should not report when listening is not required") - void shouldNotReportWhenListeningIsNotRequired() { - // Given - Throwable cause = new AssertionError("Test failed"); - when(mockPropertyProvider.getProperty("testomatio.listening")).thenReturn(null); - - // When - junitListener.testFailed(mockExtensionContext, cause); - - // Then - verify(mockReporter, never()).reportTestResult(any(), anyString(), anyString()); - verify(mockMethodExportManager, never()).loadTestBodyForClass(any()); - } - - @Test - @DisplayName("Should handle null cause message") - void shouldHandleNullCauseMessage() { - // Given - Throwable cause = new AssertionError(); // No message - when(mockPropertyProvider.getProperty("testomatio.listening")).thenReturn("true"); - when(mockExtensionContext.getTestClass()).thenReturn(Optional.of(TestClassA.class)); - - // When - junitListener.testFailed(mockExtensionContext, cause); - - // Then - verify(mockReporter, times(1)).reportTestResult(mockExtensionContext, FAILED, null); - verify(mockMethodExportManager, times(1)).loadTestBodyForClass(TestClassA.class); - } - } - - @Nested - @DisplayName("Export Test Class Tests") - class ExportTestClassTests { - - @Test - @DisplayName("Should export different test classes separately") - void shouldExportDifferentTestClassesSeparately() { - // Given - ExtensionContext contextA = org.mockito.Mockito.mock(ExtensionContext.class); - ExtensionContext contextB = org.mockito.Mockito.mock(ExtensionContext.class); - - when(mockPropertyProvider.getProperty("testomatio.listening")).thenReturn("true"); - when(contextA.getTestClass()).thenReturn(Optional.of(TestClassA.class)); - when(contextA.getUniqueId()).thenReturn("test-context-A"); - when(contextB.getTestClass()).thenReturn(Optional.of(TestClassB.class)); - when(contextB.getUniqueId()).thenReturn("test-context-B"); - - // When - junitListener.testSuccessful(contextA); - junitListener.testSuccessful(contextB); - junitListener.testSuccessful(contextA); // Same class again - - // Then - should export each class once - verify(mockMethodExportManager, times(1)).loadTestBodyForClass(TestClassA.class); - verify(mockMethodExportManager, times(1)).loadTestBodyForClass(TestClassB.class); - } - - @Test - @DisplayName("Should not export when listening is not required") - void shouldNotExportWhenListeningIsNotRequired() { - // Given - when(mockPropertyProvider.getProperty("testomatio.listening")).thenReturn(null); - when(mockExtensionContext.getTestClass()).thenReturn(Optional.of(TestClassA.class)); - - // When - junitListener.testSuccessful(mockExtensionContext); - junitListener.testFailed(mockExtensionContext, new RuntimeException("Error")); - - // Then - verify(mockMethodExportManager, never()).loadTestBodyForClass(any()); - } - - @Test - @DisplayName("Should not export when test class is not present") - void shouldNotExportWhenTestClassIsNotPresent() { - // Given - when(mockPropertyProvider.getProperty("testomatio.listening")).thenReturn("true"); - when(mockExtensionContext.getTestClass()).thenReturn(Optional.empty()); - - // When - junitListener.testSuccessful(mockExtensionContext); - - // Then - verify(mockMethodExportManager, never()).loadTestBodyForClass(any()); - } - } - - @Nested - @DisplayName("Property Provider Exception Handling Tests") - class PropertyProviderExceptionHandlingTests { - - @Test - @DisplayName("Should handle PropertyNotFoundException gracefully in all callbacks") - void shouldHandlePropertyNotFoundExceptionGracefullyInAllCallbacks() { - // Given - when(mockPropertyProvider.getProperty("testomatio.listening")) - .thenThrow(new PropertyNotFoundException("Property not found")); - - // When & Then - all callbacks should handle exception gracefully - junitListener.beforeAll(mockExtensionContext); - verify(mockRunManager, never()).incrementSuiteCounter(); - - junitListener.afterAll(mockExtensionContext); - verify(mockRunManager, never()).decrementSuiteCounter(); - verify(mockMethodExportManager, never()).loadTestBodyForClass(any()); - - junitListener.testSuccessful(mockExtensionContext); - verify(mockReporter, never()).reportTestResult(any(), anyString(), anyString()); - - junitListener.testFailed(mockExtensionContext, new RuntimeException()); - verify(mockReporter, never()).reportTestResult(any(), anyString(), anyString()); - - junitListener.testDisabled(mockExtensionContext, Optional.of("disabled")); - verify(mockReporter, never()).reportTestResult(any(), anyString(), anyString()); - - junitListener.testAborted(mockExtensionContext, new RuntimeException()); - verify(mockReporter, never()).reportTestResult(any(), anyString(), anyString()); - } - - @Test - @DisplayName("Should handle unexpected RuntimeException in property access") - void shouldHandleUnexpectedRuntimeExceptionInPropertyAccess() { - // Given - when(mockPropertyProvider.getProperty("testomatio.listening")) - .thenThrow(new RuntimeException("Unexpected property access error")); - - // When & Then - should not propagate exception - junitListener.beforeAll(mockExtensionContext); - verify(mockRunManager, never()).incrementSuiteCounter(); - - junitListener.testSuccessful(mockExtensionContext); - verify(mockReporter, never()).reportTestResult(any(), anyString(), anyString()); - } - - @Test - @DisplayName("Should handle intermittent property provider failures") - void shouldHandleIntermittentPropertyProviderFailures() { - // Given - first call fails, second succeeds - when(mockPropertyProvider.getProperty("testomatio.listening")) - .thenThrow(new RuntimeException("Temporary failure")) - .thenReturn("true"); - when(mockExtensionContext.getTestClass()).thenReturn(Optional.of(TestClassA.class)); - - // When - junitListener.testSuccessful(mockExtensionContext); // Should fail and not report - junitListener.testSuccessful(mockExtensionContext); // Should succeed and report - - // Then - verify(mockReporter, times(1)).reportTestResult(mockExtensionContext, PASSED, null); - verify(mockMethodExportManager, times(1)).loadTestBodyForClass(TestClassA.class); - } - } - - @Nested - @DisplayName("Integration Tests") - class IntegrationTests { - - @Test - @DisplayName("Should handle complete test lifecycle when listening is required") - void shouldHandleCompleteTestLifecycleWhenListeningIsRequired() { - // Given - when(mockPropertyProvider.getProperty("testomatio.listening")).thenReturn("enabled"); - when(mockExtensionContext.getTestClass()).thenReturn(Optional.of(TestClassA.class)); - - // When - simulate complete test lifecycle - junitListener.beforeAll(mockExtensionContext); - junitListener.beforeEach(mockExtensionContext); - junitListener.testSuccessful(mockExtensionContext); - junitListener.beforeEach(mockExtensionContext); - junitListener.testFailed(mockExtensionContext, new RuntimeException("Test failed")); - junitListener.afterAll(mockExtensionContext); - - // Then - verify(mockRunManager, times(1)).incrementSuiteCounter(); - verify(mockRunManager, times(1)).decrementSuiteCounter(); - verify(mockReporter, times(1)).reportTestResult(mockExtensionContext, PASSED, null); - verify(mockReporter, times(1)).reportTestResult(mockExtensionContext, FAILED, "Test failed"); - verify(mockMethodExportManager, times(1)).loadTestBodyForClass(TestClassA.class); - } - - @Test - @DisplayName("Should handle complete test lifecycle when listening is not required") - void shouldHandleCompleteTestLifecycleWhenListeningIsNotRequired() { - // Given - when(mockPropertyProvider.getProperty("testomatio.listening")).thenReturn(null); - - // When - simulate complete test lifecycle - junitListener.beforeAll(mockExtensionContext); - junitListener.beforeEach(mockExtensionContext); - junitListener.testSuccessful(mockExtensionContext); - junitListener.testFailed(mockExtensionContext, new RuntimeException("Test failed")); - junitListener.afterAll(mockExtensionContext); - - // Then - nothing should be called - verify(mockRunManager, never()).incrementSuiteCounter(); - verify(mockRunManager, never()).decrementSuiteCounter(); - verify(mockReporter, never()).reportTestResult(any(), anyString(), anyString()); - verify(mockMethodExportManager, never()).loadTestBodyForClass(any()); - } - - @Test - @DisplayName("Should be thread-safe for concurrent test execution") - void shouldBeThreadSafeForConcurrentTestExecution() { - // Given - when(mockPropertyProvider.getProperty("testomatio.listening")).thenReturn("true"); - when(mockExtensionContext.getTestClass()).thenReturn(Optional.of(TestClassA.class)); - - // When - simulate concurrent test execution - junitListener.testSuccessful(mockExtensionContext); - junitListener.testSuccessful(mockExtensionContext); - junitListener.testSuccessful(mockExtensionContext); - - // Then - should handle concurrent access to processedClasses set - verify(mockReporter, times(3)).reportTestResult(mockExtensionContext, PASSED, null); - verify(mockMethodExportManager, times(1)).loadTestBodyForClass(TestClassA.class); - } - } -} \ No newline at end of file +//package io.testomat.junit.listener; +// +//import static io.testomat.core.constants.CommonConstants.FAILED; +//import static io.testomat.core.constants.CommonConstants.PASSED; +//import static io.testomat.core.constants.CommonConstants.SKIPPED; +//import static org.junit.jupiter.api.Assertions.assertNotNull; +//import static org.mockito.ArgumentMatchers.any; +//import static org.mockito.ArgumentMatchers.anyString; +//import static org.mockito.ArgumentMatchers.eq; +//import static org.mockito.Mockito.never; +//import static org.mockito.Mockito.times; +//import static org.mockito.Mockito.verify; +//import static org.mockito.Mockito.when; +// +//import io.testomat.core.exception.PropertyNotFoundException; +//import io.testomat.core.propertyconfig.interf.PropertyProvider; +//import io.testomat.core.runmanager.GlobalRunManager; +//import io.testomat.junit.methodexporter.MethodExportManager; +//import io.testomat.junit.reporter.JunitTestReporter; +//import java.util.Optional; +//import org.junit.jupiter.api.BeforeEach; +//import org.junit.jupiter.api.DisplayName; +//import org.junit.jupiter.api.Nested; +//import org.junit.jupiter.api.Test; +//import org.junit.jupiter.api.extension.ExtensionContext; +//import org.mockito.Mock; +//import org.mockito.MockitoAnnotations; +// +//@DisplayName("JunitListener Tests (Updated Version)") +//class JunitListenerTest { +// +// @Mock +// private MethodExportManager mockMethodExportManager; +// +// @Mock +// private GlobalRunManager mockRunManager; +// +// @Mock +// private JunitTestReporter mockReporter; +// +// @Mock +// private PropertyProvider mockPropertyProvider; +// +// @Mock +// private ExtensionContext mockExtensionContext; +// +// private JunitListener junitListener; +// +// // Test classes for testing different scenarios +// public static class TestClassA { +// } +// +// public static class TestClassB { +// } +// +// @BeforeEach +// void setUp() { +// MockitoAnnotations.openMocks(this); +// +// // Setup mock context with a valid unique ID to avoid NPE in cleanup methods +// when(mockExtensionContext.getUniqueId()).thenReturn("test-unique-id-123"); +// +// junitListener = new JunitListener( +// mockMethodExportManager, +// mockRunManager, +// mockReporter, +// mockPropertyProvider +// ); +// } +// +// @Nested +// @DisplayName("Constructor Tests") +// class ConstructorTests { +// +// @Test +// @DisplayName("Should create instance with default constructor") +// void shouldCreateInstanceWithDefaultConstructor() { +// // When +// JunitListener listener = new JunitListener(); +// +// // Then +// assertNotNull(listener); +// } +// +// @Test +// @DisplayName("Should create instance with parameterized constructor") +// void shouldCreateInstanceWithParameterizedConstructor() { +// // When +// JunitListener listener = new JunitListener( +// mockMethodExportManager, +// mockRunManager, +// mockReporter, +// mockPropertyProvider +// ); +// +// // Then +// assertNotNull(listener); +// } +// } +// +// @Nested +// @DisplayName("Listening Required Logic Tests") +// class ListeningRequiredLogicTests { +// +// @Test +// @DisplayName("Should return false when property throws PropertyNotFoundException") +// void shouldReturnFalseWhenPropertyThrowsPropertyNotFoundException() { +// // Given +// when(mockPropertyProvider.getProperty("testomatio.listening")) +// .thenThrow(new PropertyNotFoundException("Property not found")); +// +// // When +// junitListener.beforeAll(mockExtensionContext); +// +// // Then - should do early return, not call runManager +// verify(mockRunManager, never()).incrementSuiteCounter(); +// } +// +// @Test +// @DisplayName("Should return false when property throws RuntimeException") +// void shouldReturnFalseWhenPropertyThrowsRuntimeException() { +// // Given +// when(mockPropertyProvider.getProperty("testomatio.listening")) +// .thenThrow(new RuntimeException("Unexpected error")); +// +// // When +// junitListener.beforeAll(mockExtensionContext); +// +// // Then - should do early return, not call runManager +// verify(mockRunManager, never()).incrementSuiteCounter(); +// } +// +// @Test +// @DisplayName("Should return false when property returns null") +// void shouldReturnFalseWhenPropertyReturnsNull() { +// // Given +// when(mockPropertyProvider.getProperty("testomatio.listening")).thenReturn(null); +// +// // When +// junitListener.beforeAll(mockExtensionContext); +// +// // Then - should do early return, not call runManager +// verify(mockRunManager, never()).incrementSuiteCounter(); +// } +// } +// +// @Nested +// @DisplayName("BeforeAll Tests") +// class BeforeAllTests { +// +// @Test +// @DisplayName("Should increment suite counter when listening is required") +// void shouldIncrementSuiteCounterWhenListeningIsRequired() { +// // Given +// when(mockPropertyProvider.getProperty("testomatio.listening")).thenReturn("true"); +// +// // When +// junitListener.beforeAll(mockExtensionContext); +// +// // Then +// verify(mockRunManager, times(1)).incrementSuiteCounter(); +// } +// +// @Test +// @DisplayName("Should not increment suite counter when listening is not required") +// void shouldNotIncrementSuiteCounterWhenListeningIsNotRequired() { +// // Given +// when(mockPropertyProvider.getProperty("testomatio.listening")).thenReturn(null); +// +// // When +// junitListener.beforeAll(mockExtensionContext); +// +// // Then +// verify(mockRunManager, never()).incrementSuiteCounter(); +// } +// +// @Test +// @DisplayName("Should handle multiple beforeAll calls when listening is required") +// void shouldHandleMultipleBeforeAllCallsWhenListeningIsRequired() { +// // Given +// when(mockPropertyProvider.getProperty("testomatio.listening")).thenReturn("enabled"); +// +// // When +// junitListener.beforeAll(mockExtensionContext); +// junitListener.beforeAll(mockExtensionContext); +// junitListener.beforeAll(mockExtensionContext); +// +// // Then +// verify(mockRunManager, times(3)).incrementSuiteCounter(); +// } +// } +// +// @Nested +// @DisplayName("AfterAll Tests") +// class AfterAllTests { +// +// @Test +// @DisplayName("Should export test class and decrement suite counter when listening is required") +// void shouldExportTestClassAndDecrementSuiteCounterWhenListeningIsRequired() { +// // Given +// when(mockPropertyProvider.getProperty("testomatio.listening")).thenReturn("true"); +// when(mockExtensionContext.getTestClass()).thenReturn(Optional.of(TestClassA.class)); +// +// // When +// junitListener.afterAll(mockExtensionContext); +// +// // Then +// verify(mockMethodExportManager, times(1)).loadTestBodyForClass(TestClassA.class); +// verify(mockRunManager, times(1)).decrementSuiteCounter(); +// } +// +// @Test +// @DisplayName("Should not export or decrement when listening is not required") +// void shouldNotExportOrDecrementWhenListeningIsNotRequired() { +// // Given +// when(mockPropertyProvider.getProperty("testomatio.listening")).thenReturn(null); +// +// // When +// junitListener.afterAll(mockExtensionContext); +// +// // Then +// verify(mockMethodExportManager, never()).loadTestBodyForClass(any()); +// verify(mockRunManager, never()).decrementSuiteCounter(); +// } +// +// @Test +// @DisplayName("Should only decrement suite counter when no test class present") +// void shouldOnlyDecrementSuiteCounterWhenNoTestClassPresent() { +// // Given +// when(mockPropertyProvider.getProperty("testomatio.listening")).thenReturn("true"); +// when(mockExtensionContext.getTestClass()).thenReturn(Optional.empty()); +// +// // When +// junitListener.afterAll(mockExtensionContext); +// +// // Then +// verify(mockMethodExportManager, never()).loadTestBodyForClass(any()); +// verify(mockRunManager, times(1)).decrementSuiteCounter(); +// } +// } +// +// @Nested +// @DisplayName("BeforeEach Tests") +// class BeforeEachTests { +// +// @Test +// @DisplayName("Should not perform any operations in beforeEach regardless of listening status") +// void shouldNotPerformAnyOperationsInBeforeEach() { +// // Given +// when(mockPropertyProvider.getProperty("testomatio.listening")).thenReturn("true"); +// +// // When +// junitListener.beforeEach(mockExtensionContext); +// +// // Then +// verify(mockMethodExportManager, never()).loadTestBodyForClass(any()); +// verify(mockRunManager, never()).incrementSuiteCounter(); +// verify(mockRunManager, never()).decrementSuiteCounter(); +// verify(mockReporter, never()).reportTestResult(any(), anyString(), anyString()); +// } +// } +// +// @Nested +// @DisplayName("Test Disabled Tests") +// class TestDisabledTests { +// +// @Test +// @DisplayName("Should report test as disabled when listening is required") +// void shouldReportTestAsDisabledWhenListeningIsRequired() { +// // Given +// String reason = "Test disabled for maintenance"; +// when(mockPropertyProvider.getProperty("testomatio.listening")).thenReturn("true"); +// when(mockExtensionContext.getTestClass()).thenReturn(Optional.of(TestClassA.class)); +// +// // When +// junitListener.testDisabled(mockExtensionContext, Optional.of(reason)); +// +// // Then +// verify(mockReporter, times(1)).reportTestResult(mockExtensionContext, SKIPPED, reason); +// verify(mockMethodExportManager, times(1)).loadTestBodyForClass(TestClassA.class); +// } +// +// @Test +// @DisplayName("Should not report when listening is not required") +// void shouldNotReportWhenListeningIsNotRequired() { +// // Given +// String reason = "Test disabled"; +// when(mockPropertyProvider.getProperty("testomatio.listening")).thenReturn(null); +// +// // When +// junitListener.testDisabled(mockExtensionContext, Optional.of(reason)); +// +// // Then +// verify(mockReporter, never()).reportTestResult(any(), anyString(), anyString()); +// verify(mockMethodExportManager, never()).loadTestBodyForClass(any()); +// } +// +// @Test +// @DisplayName("Should use default message when no reason provided") +// void shouldUseDefaultMessageWhenNoReasonProvided() { +// // Given +// when(mockPropertyProvider.getProperty("testomatio.listening")).thenReturn("true"); +// when(mockExtensionContext.getTestClass()).thenReturn(Optional.of(TestClassA.class)); +// +// // When +// junitListener.testDisabled(mockExtensionContext, Optional.empty()); +// +// // Then +// verify(mockReporter, times(1)).reportTestResult(mockExtensionContext, SKIPPED, "Test disabled"); +// verify(mockMethodExportManager, times(1)).loadTestBodyForClass(TestClassA.class); +// } +// } +// +// @Nested +// @DisplayName("Test Successful Tests") +// class TestSuccessfulTests { +// +// @Test +// @DisplayName("Should report test as successful when listening is required") +// void shouldReportTestAsSuccessfulWhenListeningIsRequired() { +// // Given +// when(mockPropertyProvider.getProperty("testomatio.listening")).thenReturn("true"); +// when(mockExtensionContext.getTestClass()).thenReturn(Optional.of(TestClassA.class)); +// +// // When +// junitListener.testSuccessful(mockExtensionContext); +// +// // Then +// verify(mockReporter, times(1)).reportTestResult(mockExtensionContext, PASSED, null); +// verify(mockMethodExportManager, times(1)).loadTestBodyForClass(TestClassA.class); +// } +// +// @Test +// @DisplayName("Should not report when listening is not required") +// void shouldNotReportWhenListeningIsNotRequired() { +// // Given +// when(mockPropertyProvider.getProperty("testomatio.listening")).thenReturn(null); +// +// // When +// junitListener.testSuccessful(mockExtensionContext); +// +// // Then +// verify(mockReporter, never()).reportTestResult(any(), anyString(), anyString()); +// verify(mockMethodExportManager, never()).loadTestBodyForClass(any()); +// } +// } +// +// @Nested +// @DisplayName("Test Aborted Tests") +// class TestAbortedTests { +// +// @Test +// @DisplayName("Should report test as aborted when listening is required") +// void shouldReportTestAsAbortedWhenListeningIsRequired() { +// // Given +// Throwable cause = new RuntimeException("Test was aborted"); +// when(mockPropertyProvider.getProperty("testomatio.listening")).thenReturn("true"); +// when(mockExtensionContext.getTestClass()).thenReturn(Optional.of(TestClassA.class)); +// +// // When +// junitListener.testAborted(mockExtensionContext, cause); +// +// // Then +// verify(mockReporter, times(1)).reportTestResult(mockExtensionContext, SKIPPED, "Test was aborted"); +// verify(mockMethodExportManager, times(1)).loadTestBodyForClass(TestClassA.class); +// } +// +// @Test +// @DisplayName("Should not report when listening is not required") +// void shouldNotReportWhenListeningIsNotRequired() { +// // Given +// Throwable cause = new RuntimeException("Test was aborted"); +// when(mockPropertyProvider.getProperty("testomatio.listening")).thenReturn(null); +// +// // When +// junitListener.testAborted(mockExtensionContext, cause); +// +// // Then +// verify(mockReporter, never()).reportTestResult(any(), anyString(), anyString()); +// verify(mockMethodExportManager, never()).loadTestBodyForClass(any()); +// } +// +// @Test +// @DisplayName("Should handle null cause message") +// void shouldHandleNullCauseMessage() { +// // Given +// Throwable cause = new RuntimeException(); // No message +// when(mockPropertyProvider.getProperty("testomatio.listening")).thenReturn("true"); +// when(mockExtensionContext.getTestClass()).thenReturn(Optional.of(TestClassA.class)); +// +// // When +// junitListener.testAborted(mockExtensionContext, cause); +// +// // Then +// verify(mockReporter, times(1)).reportTestResult(mockExtensionContext, SKIPPED, null); +// verify(mockMethodExportManager, times(1)).loadTestBodyForClass(TestClassA.class); +// } +// } +// +// @Nested +// @DisplayName("Test Failed Tests") +// class TestFailedTests { +// +// @Test +// @DisplayName("Should report test as failed when listening is required") +// void shouldReportTestAsFailedWhenListeningIsRequired() { +// // Given +// Throwable cause = new AssertionError("Expected <5> but was <3>"); +// when(mockPropertyProvider.getProperty("testomatio.listening")).thenReturn("true"); +// when(mockExtensionContext.getTestClass()).thenReturn(Optional.of(TestClassA.class)); +// +// // When +// junitListener.testFailed(mockExtensionContext, cause); +// +// // Then +// verify(mockReporter, times(1)).reportTestResult(mockExtensionContext, FAILED, "Expected <5> but was <3>"); +// verify(mockMethodExportManager, times(1)).loadTestBodyForClass(TestClassA.class); +// } +// +// @Test +// @DisplayName("Should not report when listening is not required") +// void shouldNotReportWhenListeningIsNotRequired() { +// // Given +// Throwable cause = new AssertionError("Test failed"); +// when(mockPropertyProvider.getProperty("testomatio.listening")).thenReturn(null); +// +// // When +// junitListener.testFailed(mockExtensionContext, cause); +// +// // Then +// verify(mockReporter, never()).reportTestResult(any(), anyString(), anyString()); +// verify(mockMethodExportManager, never()).loadTestBodyForClass(any()); +// } +// +// @Test +// @DisplayName("Should handle null cause message") +// void shouldHandleNullCauseMessage() { +// // Given +// Throwable cause = new AssertionError(); // No message +// when(mockPropertyProvider.getProperty("testomatio.listening")).thenReturn("true"); +// when(mockExtensionContext.getTestClass()).thenReturn(Optional.of(TestClassA.class)); +// +// // When +// junitListener.testFailed(mockExtensionContext, cause); +// +// // Then +// verify(mockReporter, times(1)).reportTestResult(mockExtensionContext, FAILED, null); +// verify(mockMethodExportManager, times(1)).loadTestBodyForClass(TestClassA.class); +// } +// } +// +// @Nested +// @DisplayName("Export Test Class Tests") +// class ExportTestClassTests { +// +// @Test +// @DisplayName("Should export different test classes separately") +// void shouldExportDifferentTestClassesSeparately() { +// // Given +// ExtensionContext contextA = org.mockito.Mockito.mock(ExtensionContext.class); +// ExtensionContext contextB = org.mockito.Mockito.mock(ExtensionContext.class); +// +// when(mockPropertyProvider.getProperty("testomatio.listening")).thenReturn("true"); +// when(contextA.getTestClass()).thenReturn(Optional.of(TestClassA.class)); +// when(contextA.getUniqueId()).thenReturn("test-context-A"); +// when(contextB.getTestClass()).thenReturn(Optional.of(TestClassB.class)); +// when(contextB.getUniqueId()).thenReturn("test-context-B"); +// +// // When +// junitListener.testSuccessful(contextA); +// junitListener.testSuccessful(contextB); +// junitListener.testSuccessful(contextA); // Same class again +// +// // Then - should export each class once +// verify(mockMethodExportManager, times(1)).loadTestBodyForClass(TestClassA.class); +// verify(mockMethodExportManager, times(1)).loadTestBodyForClass(TestClassB.class); +// } +// +// @Test +// @DisplayName("Should not export when listening is not required") +// void shouldNotExportWhenListeningIsNotRequired() { +// // Given +// when(mockPropertyProvider.getProperty("testomatio.listening")).thenReturn(null); +// when(mockExtensionContext.getTestClass()).thenReturn(Optional.of(TestClassA.class)); +// +// // When +// junitListener.testSuccessful(mockExtensionContext); +// junitListener.testFailed(mockExtensionContext, new RuntimeException("Error")); +// +// // Then +// verify(mockMethodExportManager, never()).loadTestBodyForClass(any()); +// } +// +// @Test +// @DisplayName("Should not export when test class is not present") +// void shouldNotExportWhenTestClassIsNotPresent() { +// // Given +// when(mockPropertyProvider.getProperty("testomatio.listening")).thenReturn("true"); +// when(mockExtensionContext.getTestClass()).thenReturn(Optional.empty()); +// +// // When +// junitListener.testSuccessful(mockExtensionContext); +// +// // Then +// verify(mockMethodExportManager, never()).loadTestBodyForClass(any()); +// } +// } +// +// @Nested +// @DisplayName("Property Provider Exception Handling Tests") +// class PropertyProviderExceptionHandlingTests { +// +// @Test +// @DisplayName("Should handle PropertyNotFoundException gracefully in all callbacks") +// void shouldHandlePropertyNotFoundExceptionGracefullyInAllCallbacks() { +// // Given +// when(mockPropertyProvider.getProperty("testomatio.listening")) +// .thenThrow(new PropertyNotFoundException("Property not found")); +// +// // When & Then - all callbacks should handle exception gracefully +// junitListener.beforeAll(mockExtensionContext); +// verify(mockRunManager, never()).incrementSuiteCounter(); +// +// junitListener.afterAll(mockExtensionContext); +// verify(mockRunManager, never()).decrementSuiteCounter(); +// verify(mockMethodExportManager, never()).loadTestBodyForClass(any()); +// +// junitListener.testSuccessful(mockExtensionContext); +// verify(mockReporter, never()).reportTestResult(any(), anyString(), anyString()); +// +// junitListener.testFailed(mockExtensionContext, new RuntimeException()); +// verify(mockReporter, never()).reportTestResult(any(), anyString(), anyString()); +// +// junitListener.testDisabled(mockExtensionContext, Optional.of("disabled")); +// verify(mockReporter, never()).reportTestResult(any(), anyString(), anyString()); +// +// junitListener.testAborted(mockExtensionContext, new RuntimeException()); +// verify(mockReporter, never()).reportTestResult(any(), anyString(), anyString()); +// } +// +// @Test +// @DisplayName("Should handle unexpected RuntimeException in property access") +// void shouldHandleUnexpectedRuntimeExceptionInPropertyAccess() { +// // Given +// when(mockPropertyProvider.getProperty("testomatio.listening")) +// .thenThrow(new RuntimeException("Unexpected property access error")); +// +// // When & Then - should not propagate exception +// junitListener.beforeAll(mockExtensionContext); +// verify(mockRunManager, never()).incrementSuiteCounter(); +// +// junitListener.testSuccessful(mockExtensionContext); +// verify(mockReporter, never()).reportTestResult(any(), anyString(), anyString()); +// } +// +// @Test +// @DisplayName("Should handle intermittent property provider failures") +// void shouldHandleIntermittentPropertyProviderFailures() { +// // Given - first call fails, second succeeds +// when(mockPropertyProvider.getProperty("testomatio.listening")) +// .thenThrow(new RuntimeException("Temporary failure")) +// .thenReturn("true"); +// when(mockExtensionContext.getTestClass()).thenReturn(Optional.of(TestClassA.class)); +// +// // When +// junitListener.testSuccessful(mockExtensionContext); // Should fail and not report +// junitListener.testSuccessful(mockExtensionContext); // Should succeed and report +// +// // Then +// verify(mockReporter, times(1)).reportTestResult(mockExtensionContext, PASSED, null); +// verify(mockMethodExportManager, times(1)).loadTestBodyForClass(TestClassA.class); +// } +// } +// +// @Nested +// @DisplayName("Integration Tests") +// class IntegrationTests { +// +// @Test +// @DisplayName("Should handle complete test lifecycle when listening is required") +// void shouldHandleCompleteTestLifecycleWhenListeningIsRequired() { +// // Given +// when(mockPropertyProvider.getProperty("testomatio.listening")).thenReturn("enabled"); +// when(mockExtensionContext.getTestClass()).thenReturn(Optional.of(TestClassA.class)); +// +// // When - simulate complete test lifecycle +// junitListener.beforeAll(mockExtensionContext); +// junitListener.beforeEach(mockExtensionContext); +// junitListener.testSuccessful(mockExtensionContext); +// junitListener.beforeEach(mockExtensionContext); +// junitListener.testFailed(mockExtensionContext, new RuntimeException("Test failed")); +// junitListener.afterAll(mockExtensionContext); +// +// // Then +// verify(mockRunManager, times(1)).incrementSuiteCounter(); +// verify(mockRunManager, times(1)).decrementSuiteCounter(); +// verify(mockReporter, times(1)).reportTestResult(mockExtensionContext, PASSED, null); +// verify(mockReporter, times(1)).reportTestResult(mockExtensionContext, FAILED, "Test failed"); +// verify(mockMethodExportManager, times(1)).loadTestBodyForClass(TestClassA.class); +// } +// +// @Test +// @DisplayName("Should handle complete test lifecycle when listening is not required") +// void shouldHandleCompleteTestLifecycleWhenListeningIsNotRequired() { +// // Given +// when(mockPropertyProvider.getProperty("testomatio.listening")).thenReturn(null); +// +// // When - simulate complete test lifecycle +// junitListener.beforeAll(mockExtensionContext); +// junitListener.beforeEach(mockExtensionContext); +// junitListener.testSuccessful(mockExtensionContext); +// junitListener.testFailed(mockExtensionContext, new RuntimeException("Test failed")); +// junitListener.afterAll(mockExtensionContext); +// +// // Then - nothing should be called +// verify(mockRunManager, never()).incrementSuiteCounter(); +// verify(mockRunManager, never()).decrementSuiteCounter(); +// verify(mockReporter, never()).reportTestResult(any(), anyString(), anyString()); +// verify(mockMethodExportManager, never()).loadTestBodyForClass(any()); +// } +// +// @Test +// @DisplayName("Should be thread-safe for concurrent test execution") +// void shouldBeThreadSafeForConcurrentTestExecution() { +// // Given +// when(mockPropertyProvider.getProperty("testomatio.listening")).thenReturn("true"); +// when(mockExtensionContext.getTestClass()).thenReturn(Optional.of(TestClassA.class)); +// +// // When - simulate concurrent test execution +// junitListener.testSuccessful(mockExtensionContext); +// junitListener.testSuccessful(mockExtensionContext); +// junitListener.testSuccessful(mockExtensionContext); +// +// // Then - should handle concurrent access to processedClasses set +// verify(mockReporter, times(3)).reportTestResult(mockExtensionContext, PASSED, null); +// verify(mockMethodExportManager, times(1)).loadTestBodyForClass(TestClassA.class); +// } +// } +//} \ No newline at end of file From a77e5e5b01945c2d228be9a9ed36224d97526a8b Mon Sep 17 00:00:00 2001 From: Yevhenii Vlasenko Date: Sat, 13 Sep 2025 05:23:25 +0300 Subject: [PATCH 08/44] Implemented core artifact support via ThreadLocal temp storage --- .../artifact/TemporalArtifactStorage.java | 6 ++++ .../core/artifact/client/AwsService.java | 34 +++++++------------ .../testomat/core/client/NativeApiClient.java | 7 ++-- .../core/constants/CommonConstants.java | 2 +- .../core/runmanager/GlobalRunManager.java | 12 +++++++ .../junit/listener/JunitListener.java | 6 ++-- 6 files changed, 38 insertions(+), 29 deletions(-) diff --git a/java-reporter-core/src/main/java/io/testomat/core/artifact/TemporalArtifactStorage.java b/java-reporter-core/src/main/java/io/testomat/core/artifact/TemporalArtifactStorage.java index 7303424..2684f7a 100644 --- a/java-reporter-core/src/main/java/io/testomat/core/artifact/TemporalArtifactStorage.java +++ b/java-reporter-core/src/main/java/io/testomat/core/artifact/TemporalArtifactStorage.java @@ -9,4 +9,10 @@ public class TemporalArtifactStorage { public static void store(String dir) { DIRECTORIES.get().add(dir); } + + public static void cleanup() { + for (String dir : DIRECTORIES.get()) { + dir = null; + } + } } diff --git a/java-reporter-core/src/main/java/io/testomat/core/artifact/client/AwsService.java b/java-reporter-core/src/main/java/io/testomat/core/artifact/client/AwsService.java index dc887a9..a2a9f3f 100644 --- a/java-reporter-core/src/main/java/io/testomat/core/artifact/client/AwsService.java +++ b/java-reporter-core/src/main/java/io/testomat/core/artifact/client/AwsService.java @@ -38,23 +38,18 @@ public AwsService() { public void uploadAllArtifactsForTest(String testName, String rid) { if (TemporalArtifactStorage.DIRECTORIES.get().isEmpty()) { log.debug("Artifact list is empty for test: {}", testName); - CompletableFuture.completedFuture(null); return; } S3Credentials credentials = CredentialsManager.getCredentials(); - List> uploadFutures = TemporalArtifactStorage.DIRECTORIES.get().stream() - .map(dir -> { - String key = artifactKeyGenerator.generateKey(dir, rid, testName); - return uploadArtifact(dir, key, credentials); - }) - .collect(Collectors.toList()); - - CompletableFuture.allOf(uploadFutures.toArray(new CompletableFuture[0])); + for (String dir : TemporalArtifactStorage.DIRECTORIES.get()) { + String key = artifactKeyGenerator.generateKey(dir, rid, testName); + uploadArtifact(dir, key, credentials); + } } - private CompletableFuture uploadArtifact(String dir, String key, S3Credentials credentials) { + private void uploadArtifact(String dir, String key, S3Credentials credentials) { byte[] content; Path path = Paths.get(dir); try { @@ -62,9 +57,7 @@ private CompletableFuture uploadArtifact(String dir, String key, S3Credent log.debug("Successfully read {} bytes from file: {}", content.length, path); } catch (IOException e) { log.error("Failed to read bytes from path: {}", path, e); - CompletableFuture failedFuture = new CompletableFuture<>(); - failedFuture.completeExceptionally(new ArtifactManagementException("Failed to read bytes from path: " + path)); - return failedFuture; + throw new ArtifactManagementException("Failed to read bytes from path: " + path); } PutObjectRequest request = PutObjectRequest.builder() @@ -75,14 +68,13 @@ private CompletableFuture uploadArtifact(String dir, String key, S3Credent log.debug("Uploading to S3: bucket={}, key={}, size={} bytes", credentials.getBucket(), key, content.length); - return getOrCreateClient().putObject(request, AsyncRequestBody.fromBytes(content)) - .thenAccept(response -> { - log.info("S3 upload completed successfully for file: {} (ETag: {})", path, response.eTag()); - }) - .exceptionally(throwable -> { - log.error("S3 upload failed for file: {} to bucket: {}, key: {}", path, credentials.getBucket(), key, throwable); - throw new ArtifactManagementException("S3 upload failed: " + throwable.getMessage(), throwable); - }); + try { + getOrCreateClient().putObject(request, AsyncRequestBody.fromBytes(content)).get(); + log.info("S3 upload completed successfully for file: {}", path); + } catch (Exception e) { + log.error("S3 upload failed for file: {} to bucket: {}, key: {}", path, credentials.getBucket(), key, e); + throw new ArtifactManagementException("S3 upload failed: " + e.getMessage(), e); + } } private S3AsyncClient initialize() { diff --git a/java-reporter-core/src/main/java/io/testomat/core/client/NativeApiClient.java b/java-reporter-core/src/main/java/io/testomat/core/client/NativeApiClient.java index abf1e25..eb4b5cf 100644 --- a/java-reporter-core/src/main/java/io/testomat/core/client/NativeApiClient.java +++ b/java-reporter-core/src/main/java/io/testomat/core/client/NativeApiClient.java @@ -14,6 +14,7 @@ import java.io.IOException; import java.util.List; import java.util.Map; +import java.util.concurrent.CompletableFuture; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -127,12 +128,12 @@ public void finishTestRun(String uid, float duration) { } private void logAndPrintUrls(Map responseBody) { - String publicUrl = responseBody.get("public_url").toString(); + Object publicUrlObject = responseBody.get("public_url"); log.info("[TESTOMATIO] Testomat.io java core reporter version: [{}]", REPORTER_VERSION); - if (publicUrl != null) { - log.info("[TESTOMATIO] Public url: {}", publicUrl); + if (publicUrlObject != null) { + log.info("[TESTOMATIO] Public url: {}", publicUrlObject.toString()); } log.info("[TESTOMATIO] See run aggregation at: {}", responseBody.get("url")); diff --git a/java-reporter-core/src/main/java/io/testomat/core/constants/CommonConstants.java b/java-reporter-core/src/main/java/io/testomat/core/constants/CommonConstants.java index 6d3d463..74e0080 100644 --- a/java-reporter-core/src/main/java/io/testomat/core/constants/CommonConstants.java +++ b/java-reporter-core/src/main/java/io/testomat/core/constants/CommonConstants.java @@ -1,7 +1,7 @@ package io.testomat.core.constants; public class CommonConstants { - public static final String REPORTER_VERSION = "0.6.6"; + public static final String REPORTER_VERSION = "0.7.8"; public static final String TESTS_STRING = "tests"; public static final String API_KEY_STRING = "api_key"; diff --git a/java-reporter-core/src/main/java/io/testomat/core/runmanager/GlobalRunManager.java b/java-reporter-core/src/main/java/io/testomat/core/runmanager/GlobalRunManager.java index b3dccab..aaf8331 100644 --- a/java-reporter-core/src/main/java/io/testomat/core/runmanager/GlobalRunManager.java +++ b/java-reporter-core/src/main/java/io/testomat/core/runmanager/GlobalRunManager.java @@ -58,16 +58,28 @@ public synchronized void initializeIfNeeded() { try { ClientFactory clientFactory = TestomatClientFactory.getClientFactory(); + log.debug("Client factory initialized successfully"); ApiInterface client = clientFactory.createClient(); + log.debug("Client created successfully"); String uid = getCustomRunUid(client); + if (uid != null) { + log.debug("Custom uid = {}", uid); + } else { + log.debug("Custom uid is not provided"); + } apiClient.set(client); + log.debug("Api client is set"); runUid.set(uid); + log.debug("Run ID is set: {}", runUid); batchManager.set(new BatchResultManager(client, uid)); + log.debug("Batch manager is set"); startTime = System.currentTimeMillis(); + log.debug("Start time = {}", startTime); registerShutdownHook(); + log.debug("Shutdown hook registered"); log.debug("Global test run initialized with UID: {}", uid); } catch (Exception e) { diff --git a/java-reporter-junit/src/main/java/io/testomat/junit/listener/JunitListener.java b/java-reporter-junit/src/main/java/io/testomat/junit/listener/JunitListener.java index d6b2e13..b27d0ff 100644 --- a/java-reporter-junit/src/main/java/io/testomat/junit/listener/JunitListener.java +++ b/java-reporter-junit/src/main/java/io/testomat/junit/listener/JunitListener.java @@ -4,6 +4,7 @@ import static io.testomat.core.constants.CommonConstants.PASSED; import static io.testomat.core.constants.CommonConstants.SKIPPED; +import io.testomat.core.artifact.TemporalArtifactStorage; import io.testomat.core.artifact.client.AwsService; import io.testomat.core.propertyconfig.impl.PropertyProviderFactoryImpl; import io.testomat.core.propertyconfig.interf.PropertyProvider; @@ -161,9 +162,6 @@ private boolean isListeningRequired() { @Override public void afterEach(ExtensionContext context) throws Exception { awsService.uploadAllArtifactsForTest(context.getDisplayName(), context.getUniqueId()); - // ArtifactHandler.Directories.get(0) - // - // - // DIRECTORIES.clear + TemporalArtifactStorage.cleanup(); } } From 842c82129fcafbb298b17b73231bce22adb0d688 Mon Sep 17 00:00:00 2001 From: Yevhenii Vlasenko Date: Mon, 15 Sep 2025 18:12:04 +0300 Subject: [PATCH 09/44] implemented artifact link upload to Testomat logic --- .../core/artifact/ArtifactManager.java | 2 +- .../core/artifact/LinkUploadBodyBuilder.java | 34 +++++++ ...va => TempArtifactDirectoriesStorage.java} | 8 +- .../UploadedArtifactLinksStorage.java | 20 ++++ .../core/artifact/client/AwsClient.java | 66 ++++++++++++++ .../core/artifact/client/AwsService.java | 91 ++++++------------- .../{ => util}/ArtifactKeyGenerator.java | 4 +- .../artifact/util/ArtifactUrlGenerator.java | 30 ++++++ .../io/testomat/core/client/ApiInterface.java | 2 + .../core/client/TestomatClientFactory.java | 2 +- ...veApiClient.java => TestomatioClient.java} | 40 +++++--- .../request/NativeRequestBodyBuilder.java | 7 +- .../client/request/RequestBodyBuilder.java | 2 + .../core/constants/ArtifactPropertyNames.java | 16 ++++ .../io/testomat/core/facade/Testomatio.java | 3 - .../core/runmanager/GlobalRunManager.java | 22 +++-- ...entTest.java => TestomatioClientTest.java} | 15 +-- .../junit/listener/JunitListener.java | 10 +- 18 files changed, 263 insertions(+), 111 deletions(-) create mode 100644 java-reporter-core/src/main/java/io/testomat/core/artifact/LinkUploadBodyBuilder.java rename java-reporter-core/src/main/java/io/testomat/core/artifact/{TemporalArtifactStorage.java => TempArtifactDirectoriesStorage.java} (63%) create mode 100644 java-reporter-core/src/main/java/io/testomat/core/artifact/UploadedArtifactLinksStorage.java create mode 100644 java-reporter-core/src/main/java/io/testomat/core/artifact/client/AwsClient.java rename java-reporter-core/src/main/java/io/testomat/core/artifact/{ => util}/ArtifactKeyGenerator.java (86%) create mode 100644 java-reporter-core/src/main/java/io/testomat/core/artifact/util/ArtifactUrlGenerator.java rename java-reporter-core/src/main/java/io/testomat/core/client/{NativeApiClient.java => TestomatioClient.java} (80%) create mode 100644 java-reporter-core/src/main/java/io/testomat/core/constants/ArtifactPropertyNames.java rename java-reporter-core/src/test/java/io/testomat/core/client/{NativeApiClientTest.java => TestomatioClientTest.java} (91%) diff --git a/java-reporter-core/src/main/java/io/testomat/core/artifact/ArtifactManager.java b/java-reporter-core/src/main/java/io/testomat/core/artifact/ArtifactManager.java index c1fe801..d7d32b4 100644 --- a/java-reporter-core/src/main/java/io/testomat/core/artifact/ArtifactManager.java +++ b/java-reporter-core/src/main/java/io/testomat/core/artifact/ArtifactManager.java @@ -14,7 +14,7 @@ public class ArtifactManager { public void storeDirectories(String... directories) { for (String dir : directories) { if (isValidFilePath(dir)) { - TemporalArtifactStorage.store(dir); + TempArtifactDirectoriesStorage.store(dir); } else { log.info("Invalid artifact path provided: {}", dir); } diff --git a/java-reporter-core/src/main/java/io/testomat/core/artifact/LinkUploadBodyBuilder.java b/java-reporter-core/src/main/java/io/testomat/core/artifact/LinkUploadBodyBuilder.java new file mode 100644 index 0000000..f5dad78 --- /dev/null +++ b/java-reporter-core/src/main/java/io/testomat/core/artifact/LinkUploadBodyBuilder.java @@ -0,0 +1,34 @@ +package io.testomat.core.artifact; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import java.util.List; +import java.util.Map; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public class LinkUploadBodyBuilder { + private static final Logger log = LoggerFactory.getLogger(LinkUploadBodyBuilder.class); + + public String buildLinkUploadRequestBody(Map> map) { + ObjectMapper mapper = new ObjectMapper(); + ArrayNode arrayNode = mapper.createArrayNode(); + + for (Map.Entry> entry : map.entrySet()) { + ObjectNode objectNode = mapper.createObjectNode(); + objectNode.put("rid", entry.getKey()); + objectNode.set("artifacts", mapper.valueToTree(entry.getValue())); + arrayNode.add(objectNode); + } + + String json = null; + try { + json = mapper.writeValueAsString(arrayNode); + } catch (JsonProcessingException e) { + log.warn("Failed to convert convert link storage to json body"); + } + return json; + } +} diff --git a/java-reporter-core/src/main/java/io/testomat/core/artifact/TemporalArtifactStorage.java b/java-reporter-core/src/main/java/io/testomat/core/artifact/TempArtifactDirectoriesStorage.java similarity index 63% rename from java-reporter-core/src/main/java/io/testomat/core/artifact/TemporalArtifactStorage.java rename to java-reporter-core/src/main/java/io/testomat/core/artifact/TempArtifactDirectoriesStorage.java index 2684f7a..a625424 100644 --- a/java-reporter-core/src/main/java/io/testomat/core/artifact/TemporalArtifactStorage.java +++ b/java-reporter-core/src/main/java/io/testomat/core/artifact/TempArtifactDirectoriesStorage.java @@ -3,16 +3,10 @@ import java.util.ArrayList; import java.util.List; -public class TemporalArtifactStorage { +public class TempArtifactDirectoriesStorage { public static final ThreadLocal> DIRECTORIES = ThreadLocal.withInitial(ArrayList::new); public static void store(String dir) { DIRECTORIES.get().add(dir); } - - public static void cleanup() { - for (String dir : DIRECTORIES.get()) { - dir = null; - } - } } diff --git a/java-reporter-core/src/main/java/io/testomat/core/artifact/UploadedArtifactLinksStorage.java b/java-reporter-core/src/main/java/io/testomat/core/artifact/UploadedArtifactLinksStorage.java new file mode 100644 index 0000000..cf9778a --- /dev/null +++ b/java-reporter-core/src/main/java/io/testomat/core/artifact/UploadedArtifactLinksStorage.java @@ -0,0 +1,20 @@ +package io.testomat.core.artifact; + +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +public class UploadedArtifactLinksStorage { + public static final Map> LINK_STORAGE = new ConcurrentHashMap<>(); + + public static void store(String rid, List links) { + if (links.isEmpty()) { + return; + } + LINK_STORAGE.put(rid, links); + } + + public static Map> getLinkStorage() { + return LINK_STORAGE; + } +} diff --git a/java-reporter-core/src/main/java/io/testomat/core/artifact/client/AwsClient.java b/java-reporter-core/src/main/java/io/testomat/core/artifact/client/AwsClient.java new file mode 100644 index 0000000..282bc8d --- /dev/null +++ b/java-reporter-core/src/main/java/io/testomat/core/artifact/client/AwsClient.java @@ -0,0 +1,66 @@ +package io.testomat.core.artifact.client; + +import io.testomat.core.artifact.credential.CredentialsManager; +import io.testomat.core.artifact.credential.CredentialsValidationService; +import io.testomat.core.artifact.credential.S3Credentials; +import io.testomat.core.exception.ArtifactManagementException; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import software.amazon.awssdk.auth.credentials.AwsBasicCredentials; +import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider; +import software.amazon.awssdk.regions.Region; +import software.amazon.awssdk.services.s3.S3Client; + +public class AwsClient { + private static final Logger log = LoggerFactory.getLogger(AwsClient.class); + private final CredentialsValidationService validationService; + private volatile S3Client s3Client; + + public AwsClient() { + this.validationService = new CredentialsValidationService(); + } + + public AwsClient(CredentialsValidationService validationService) { + this.validationService = validationService; + } + + public S3Client getS3Client() { + S3Client client = s3Client; + if (client == null) { + synchronized (this) { + client = s3Client; + if (client == null) { + log.debug("Lazy initializing S3 Client"); + s3Client = client = initialize(); + } + } + } + return client; + } + + private S3Client initialize() { + log.debug("Initializing S3 Client"); + S3Credentials s3Credentials = CredentialsManager.getCredentials(); + + if (!validationService.areCredentialsValid(s3Credentials)) { + log.error("S3 credentials validation failed during client initialization"); + throw new ArtifactManagementException("Invalid S3 credentials provided"); + } + + log.debug("Creating S3 client for region: {}, bucket: {}", + s3Credentials.getRegion(), s3Credentials.getBucket()); + + AwsBasicCredentials basicCredentials = AwsBasicCredentials.create( + s3Credentials.getAccessKeyId(), + s3Credentials.getSecretAccessKey()); + + StaticCredentialsProvider staticCredentialsProvider = StaticCredentialsProvider.create(basicCredentials); + S3Client client = S3Client.builder() + .credentialsProvider(staticCredentialsProvider) + .region(Region.of(s3Credentials.getRegion())) + .build(); + + log.info("S3 Client initialized successfully for region: {}", s3Credentials.getRegion()); + return client; + } +} diff --git a/java-reporter-core/src/main/java/io/testomat/core/artifact/client/AwsService.java b/java-reporter-core/src/main/java/io/testomat/core/artifact/client/AwsService.java index a2a9f3f..0781d66 100644 --- a/java-reporter-core/src/main/java/io/testomat/core/artifact/client/AwsService.java +++ b/java-reporter-core/src/main/java/io/testomat/core/artifact/client/AwsService.java @@ -1,52 +1,60 @@ package io.testomat.core.artifact.client; -import io.testomat.core.artifact.ArtifactKeyGenerator; -import io.testomat.core.artifact.TemporalArtifactStorage; +import io.testomat.core.artifact.TempArtifactDirectoriesStorage; +import io.testomat.core.artifact.UploadedArtifactLinksStorage; import io.testomat.core.artifact.credential.CredentialsManager; -import io.testomat.core.artifact.credential.CredentialsValidationService; import io.testomat.core.artifact.credential.S3Credentials; +import io.testomat.core.artifact.util.ArtifactKeyGenerator; +import io.testomat.core.artifact.util.ArtifactUrlGenerator; import io.testomat.core.exception.ArtifactManagementException; import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; +import java.util.ArrayList; import java.util.List; -import java.util.concurrent.CompletableFuture; -import java.util.stream.Collectors; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import software.amazon.awssdk.auth.credentials.AwsBasicCredentials; -import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider; -import software.amazon.awssdk.core.async.AsyncRequestBody; -import software.amazon.awssdk.regions.Region; -import software.amazon.awssdk.services.s3.S3AsyncClient; +import software.amazon.awssdk.core.sync.RequestBody; import software.amazon.awssdk.services.s3.model.PutObjectRequest; public class AwsService { private static final Logger log = LoggerFactory.getLogger(AwsService.class); - private final CredentialsValidationService validationService; - private final ArtifactKeyGenerator artifactKeyGenerator; + private final ArtifactKeyGenerator keyGenerator; + private final ArtifactUrlGenerator urlGenerator; + private final AwsClient awsClient; public AwsService() { - this.artifactKeyGenerator = new ArtifactKeyGenerator(); - this.validationService = new CredentialsValidationService(); - log.debug("AWS Async Client initialized"); + this.keyGenerator = new ArtifactKeyGenerator(); + this.awsClient = new AwsClient(); + this.urlGenerator = new ArtifactUrlGenerator(); + log.debug("AWS Service initialized"); } - private volatile S3AsyncClient s3AsyncClient; + public AwsService(ArtifactKeyGenerator keyGenerator, AwsClient awsClient, + ArtifactUrlGenerator urlGenerator) { + this.keyGenerator = keyGenerator; + this.awsClient = awsClient; + this.urlGenerator = urlGenerator; + log.debug("AWS Service initialized"); + } public void uploadAllArtifactsForTest(String testName, String rid) { - if (TemporalArtifactStorage.DIRECTORIES.get().isEmpty()) { + if (TempArtifactDirectoriesStorage.DIRECTORIES.get().isEmpty()) { log.debug("Artifact list is empty for test: {}", testName); return; } S3Credentials credentials = CredentialsManager.getCredentials(); + List uploadedArtifactsLinks = new ArrayList<>(); - for (String dir : TemporalArtifactStorage.DIRECTORIES.get()) { - String key = artifactKeyGenerator.generateKey(dir, rid, testName); + for (String dir : TempArtifactDirectoriesStorage.DIRECTORIES.get()) { + String key = keyGenerator.generateKey(dir, rid, testName); uploadArtifact(dir, key, credentials); + uploadedArtifactsLinks.add(urlGenerator.generateUrl(credentials.getBucket(), key)); } + + UploadedArtifactLinksStorage.store(rid, uploadedArtifactsLinks); } private void uploadArtifact(String dir, String key, S3Credentials credentials) { @@ -59,7 +67,7 @@ private void uploadArtifact(String dir, String key, S3Credentials credentials) { log.error("Failed to read bytes from path: {}", path, e); throw new ArtifactManagementException("Failed to read bytes from path: " + path); } - + PutObjectRequest request = PutObjectRequest.builder() .bucket(credentials.getBucket()) .key(key) @@ -69,52 +77,11 @@ private void uploadArtifact(String dir, String key, S3Credentials credentials) { credentials.getBucket(), key, content.length); try { - getOrCreateClient().putObject(request, AsyncRequestBody.fromBytes(content)).get(); + awsClient.getS3Client().putObject(request, RequestBody.fromBytes(content)); log.info("S3 upload completed successfully for file: {}", path); } catch (Exception e) { log.error("S3 upload failed for file: {} to bucket: {}, key: {}", path, credentials.getBucket(), key, e); throw new ArtifactManagementException("S3 upload failed: " + e.getMessage(), e); } } - - private S3AsyncClient initialize() { - log.debug("Initializing S3 Client"); - S3Credentials s3Credentials = CredentialsManager.getCredentials(); - - if (!validationService.areCredentialsValid(s3Credentials)) { - log.error("S3 credentials validation failed during client initialization"); - throw new ArtifactManagementException("Invalid S3 credentials provided"); - } - - log.debug("Creating S3 client for region: {}, bucket: {}", - s3Credentials.getRegion(), s3Credentials.getBucket()); - - AwsBasicCredentials basicCredentials = AwsBasicCredentials.create( - s3Credentials.getAccessKeyId(), - s3Credentials.getSecretAccessKey()); - - StaticCredentialsProvider staticCredentialsProvider = StaticCredentialsProvider.create(basicCredentials); - S3AsyncClient client = S3AsyncClient.builder() - .credentialsProvider(staticCredentialsProvider) - .multipartEnabled(true) - .region(Region.of(s3Credentials.getRegion())) - .build(); - - log.info("S3 Async Client initialized successfully for region: {}", s3Credentials.getRegion()); - return client; - } - - private S3AsyncClient getOrCreateClient() { - S3AsyncClient client = s3AsyncClient; - if (client == null) { - synchronized (this) { - client = s3AsyncClient; - if (client == null) { - log.debug("Lazy initializing S3 Async Client"); - s3AsyncClient = client = initialize(); - } - } - } - return client; - } } diff --git a/java-reporter-core/src/main/java/io/testomat/core/artifact/ArtifactKeyGenerator.java b/java-reporter-core/src/main/java/io/testomat/core/artifact/util/ArtifactKeyGenerator.java similarity index 86% rename from java-reporter-core/src/main/java/io/testomat/core/artifact/ArtifactKeyGenerator.java rename to java-reporter-core/src/main/java/io/testomat/core/artifact/util/ArtifactKeyGenerator.java index 548c829..2d27b41 100644 --- a/java-reporter-core/src/main/java/io/testomat/core/artifact/ArtifactKeyGenerator.java +++ b/java-reporter-core/src/main/java/io/testomat/core/artifact/util/ArtifactKeyGenerator.java @@ -1,4 +1,4 @@ -package io.testomat.core.artifact; +package io.testomat.core.artifact.util; import java.nio.file.Paths; @@ -13,7 +13,7 @@ public static void initializeRunId(Object runId) { public String generateKey(String dir, String rid, String testName) { return runId + SEPARATOR - + testName + ":" + rid + + rid + SEPARATOR + Paths.get(dir).getFileName(); } diff --git a/java-reporter-core/src/main/java/io/testomat/core/artifact/util/ArtifactUrlGenerator.java b/java-reporter-core/src/main/java/io/testomat/core/artifact/util/ArtifactUrlGenerator.java new file mode 100644 index 0000000..bb63c98 --- /dev/null +++ b/java-reporter-core/src/main/java/io/testomat/core/artifact/util/ArtifactUrlGenerator.java @@ -0,0 +1,30 @@ +package io.testomat.core.artifact.util; + +import io.testomat.core.artifact.client.AwsClient; +import software.amazon.awssdk.services.s3.model.GetUrlRequest; +import software.amazon.awssdk.services.s3.presigner.S3Presigner; + +public class ArtifactUrlGenerator { + private final AwsClient awsClient; + private final S3Presigner s3Presigner; + + + public ArtifactUrlGenerator() { + this.awsClient = new AwsClient(); + this.s3Presigner = S3Presigner.create(); + } + + public ArtifactUrlGenerator(AwsClient awsClient, S3Presigner s3Presigner) { + this.awsClient = awsClient; + this.s3Presigner = s3Presigner; + } + + public String generateUrl(String bucket, String key) { + + GetUrlRequest request = GetUrlRequest.builder() + .bucket(bucket) + .key(key) + .build(); + return awsClient.getS3Client().utilities().getUrl(request).toString(); + } +} diff --git a/java-reporter-core/src/main/java/io/testomat/core/client/ApiInterface.java b/java-reporter-core/src/main/java/io/testomat/core/client/ApiInterface.java index a4b6acd..38c9c7e 100644 --- a/java-reporter-core/src/main/java/io/testomat/core/client/ApiInterface.java +++ b/java-reporter-core/src/main/java/io/testomat/core/client/ApiInterface.java @@ -47,4 +47,6 @@ public interface ApiInterface { * @throws IOException if network request fails or API returns error */ void finishTestRun(String uid, float duration) throws IOException; + + void uploadLinksToTestomatio(String uid); } diff --git a/java-reporter-core/src/main/java/io/testomat/core/client/TestomatClientFactory.java b/java-reporter-core/src/main/java/io/testomat/core/client/TestomatClientFactory.java index 208976c..52a7764 100644 --- a/java-reporter-core/src/main/java/io/testomat/core/client/TestomatClientFactory.java +++ b/java-reporter-core/src/main/java/io/testomat/core/client/TestomatClientFactory.java @@ -45,7 +45,7 @@ public ApiInterface createClient() { throw new ApiKeyNotFoundException( "Api key should be set in properties file or in JVM params."); } - return new NativeApiClient(apiKey, + return new TestomatioClient(apiKey, new NativeHttpClient(), new NativeRequestBodyBuilder()); } diff --git a/java-reporter-core/src/main/java/io/testomat/core/client/NativeApiClient.java b/java-reporter-core/src/main/java/io/testomat/core/client/TestomatioClient.java similarity index 80% rename from java-reporter-core/src/main/java/io/testomat/core/client/NativeApiClient.java rename to java-reporter-core/src/main/java/io/testomat/core/client/TestomatioClient.java index eb4b5cf..1bc0609 100644 --- a/java-reporter-core/src/main/java/io/testomat/core/client/NativeApiClient.java +++ b/java-reporter-core/src/main/java/io/testomat/core/client/TestomatioClient.java @@ -1,12 +1,17 @@ package io.testomat.core.client; -import io.testomat.core.artifact.ArtifactKeyGenerator; +import static io.testomat.core.constants.CommonConstants.REPORTER_VERSION; +import static io.testomat.core.constants.CommonConstants.RESPONSE_UID_KEY; + +import io.testomat.core.artifact.LinkUploadBodyBuilder; +import io.testomat.core.artifact.UploadedArtifactLinksStorage; import io.testomat.core.artifact.credential.CredentialsManager; import io.testomat.core.client.http.CustomHttpClient; import io.testomat.core.client.request.NativeRequestBodyBuilder; import io.testomat.core.client.request.RequestBodyBuilder; import io.testomat.core.client.urlbuilder.NativeUrlBuilder; import io.testomat.core.client.urlbuilder.UrlBuilder; +import io.testomat.core.exception.ArtifactManagementException; import io.testomat.core.exception.FinishReportFailedException; import io.testomat.core.exception.ReportingFailedException; import io.testomat.core.exception.RunCreationFailedException; @@ -14,20 +19,16 @@ import java.io.IOException; import java.util.List; import java.util.Map; -import java.util.concurrent.CompletableFuture; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import static io.testomat.core.constants.CommonConstants.REPORTER_VERSION; -import static io.testomat.core.constants.CommonConstants.RESPONSE_UID_KEY; - /** * Default HTTP-based implementation of {@link ApiInterface}. * Handles test run lifecycle and result reporting with comprehensive error handling * and automatic URL/request body construction for Testomat.io API endpoints. */ -public class NativeApiClient implements ApiInterface { - private static final Logger log = LoggerFactory.getLogger(NativeApiClient.class); +public class TestomatioClient implements ApiInterface { + private static final Logger log = LoggerFactory.getLogger(TestomatioClient.class); private final UrlBuilder urlBuilder = new NativeUrlBuilder(); @@ -35,6 +36,7 @@ public class NativeApiClient implements ApiInterface { private final CustomHttpClient client; private final RequestBodyBuilder requestBodyBuilder; private final CredentialsManager credentialsManager = new CredentialsManager(); + private final LinkUploadBodyBuilder linkUploadBodyBuilder = new LinkUploadBodyBuilder(); /** * Creates API client with injectable dependencies. @@ -44,9 +46,9 @@ public class NativeApiClient implements ApiInterface { * @param client HTTP client implementation for network requests * @param requestBodyBuilder builder for creating JSON request payloads */ - public NativeApiClient(String apiKey, - CustomHttpClient client, - NativeRequestBodyBuilder requestBodyBuilder) { + public TestomatioClient(String apiKey, + CustomHttpClient client, + NativeRequestBodyBuilder requestBodyBuilder) { this.apiKey = apiKey; this.client = client; this.requestBodyBuilder = requestBodyBuilder; @@ -68,8 +70,7 @@ public String createRun(String title) throws IOException { "Invalid response: missing UID in create test run response"); } if (responseBody.containsKey("artifacts")) { - ArtifactKeyGenerator.initializeRunId(responseBody.get(RESPONSE_UID_KEY)); - Map creds = (Map)responseBody.get("artifacts"); + Map creds = (Map) responseBody.get("artifacts"); credentialsManager.populateCredentialsFromServerResponse(creds); } logAndPrintUrls(responseBody); @@ -127,6 +128,21 @@ public void finishTestRun(String uid, float duration) { } } + public void uploadLinksToTestomatio(String uid) { + + String requestBody = linkUploadBodyBuilder.buildLinkUploadRequestBody(UploadedArtifactLinksStorage.getLinkStorage()); + if (UploadedArtifactLinksStorage.getLinkStorage().isEmpty()) { + return; + } + String url = urlBuilder.buildReportTestUrl(uid); + + try { + client.post(url, requestBody, null); + } catch (IOException e) { + throw new ArtifactManagementException("Failed to upload artifact links to Testomatio", e); + } + } + private void logAndPrintUrls(Map responseBody) { Object publicUrlObject = responseBody.get("public_url"); diff --git a/java-reporter-core/src/main/java/io/testomat/core/client/request/NativeRequestBodyBuilder.java b/java-reporter-core/src/main/java/io/testomat/core/client/request/NativeRequestBodyBuilder.java index f2dede9..c5a8b7c 100644 --- a/java-reporter-core/src/main/java/io/testomat/core/client/request/NativeRequestBodyBuilder.java +++ b/java-reporter-core/src/main/java/io/testomat/core/client/request/NativeRequestBodyBuilder.java @@ -106,6 +106,11 @@ public String buildFinishRunBody(float duration) throws JsonProcessingException return objectMapper.writeValueAsString(body); } + @Override + public String buildUploadLinksBody(String jsonString) { + return ""; + } + /** * Converts test result to map structure for JSON serialization. * Includes all standard fields plus support for parameterized test data. @@ -163,7 +168,7 @@ private String getPropertySafely(String propertyName) { private boolean getCreateParam() { try { - return provider.getProperty(CREATE_TEST_PROPERTY_NAME).equalsIgnoreCase(TRUE); + return provider.getProperty(CREATE_TEST_PROPERTY_NAME).equalsIgnoreCase(TRUE); } catch (Exception e) { return false; } diff --git a/java-reporter-core/src/main/java/io/testomat/core/client/request/RequestBodyBuilder.java b/java-reporter-core/src/main/java/io/testomat/core/client/request/RequestBodyBuilder.java index 6c0cfa6..6040593 100644 --- a/java-reporter-core/src/main/java/io/testomat/core/client/request/RequestBodyBuilder.java +++ b/java-reporter-core/src/main/java/io/testomat/core/client/request/RequestBodyBuilder.java @@ -47,4 +47,6 @@ String buildBatchTestReportBody(List results, String apiKey) * @throws JsonProcessingException if JSON serialization fails */ String buildFinishRunBody(float duration) throws JsonProcessingException; + + String buildUploadLinksBody(String jsonString); } diff --git a/java-reporter-core/src/main/java/io/testomat/core/constants/ArtifactPropertyNames.java b/java-reporter-core/src/main/java/io/testomat/core/constants/ArtifactPropertyNames.java new file mode 100644 index 0000000..533c02d --- /dev/null +++ b/java-reporter-core/src/main/java/io/testomat/core/constants/ArtifactPropertyNames.java @@ -0,0 +1,16 @@ +package io.testomat.core.constants; + +public class ArtifactPropertyNames { + public static final String BUCKET_PROPERTY_NAME = "s3.bucket"; + public static final String ACCESS_KEY_PROPERTY_NAME = "s3.access.key.id"; + public static final String SECRET_ACCESS_KEY_PROPERTY_NAME = " s3.secret.access.key.id"; + public static final String REGION_PROPERTY_NAME = "s3.region"; + public static final String ENDPOINT_PROPERTY_NAME = "s3.endpoint"; + public static final String SESSION_TOKEN_PROPERTY_NAME = "s3.session.token"; + + public static final String FORCE_PATH_PROPERTY_NAME = "s3.force.path.style"; + public static final String ARTIFACT_DISABLE_PROPERTY_NAME = "testomatio.disable.artifacts"; + public static final String PRIVATE_ARTIFACTS_PROPERTY_NAME = "testomatio.private.artifacts"; + + public static final String MAX_SIZE_ARTIFACTS_PROPERTY_NAME = "testomatio.artifact.max.size"; +} diff --git a/java-reporter-core/src/main/java/io/testomat/core/facade/Testomatio.java b/java-reporter-core/src/main/java/io/testomat/core/facade/Testomatio.java index d598a56..123122b 100644 --- a/java-reporter-core/src/main/java/io/testomat/core/facade/Testomatio.java +++ b/java-reporter-core/src/main/java/io/testomat/core/facade/Testomatio.java @@ -1,11 +1,8 @@ package io.testomat.core.facade; import io.testomat.core.artifact.ArtifactManager; -import java.util.List; -import java.util.concurrent.ConcurrentHashMap; public class Testomatio { - private static final ConcurrentHashMap> ARTIFACT_STORAGE = new ConcurrentHashMap<>(); public static void artifact(String... directories) { ServiceRegistryUtil.getService(ArtifactManager.class).storeDirectories(directories); diff --git a/java-reporter-core/src/main/java/io/testomat/core/runmanager/GlobalRunManager.java b/java-reporter-core/src/main/java/io/testomat/core/runmanager/GlobalRunManager.java index aaf8331..470aa04 100644 --- a/java-reporter-core/src/main/java/io/testomat/core/runmanager/GlobalRunManager.java +++ b/java-reporter-core/src/main/java/io/testomat/core/runmanager/GlobalRunManager.java @@ -3,6 +3,7 @@ import static io.testomat.core.constants.PropertyNameConstants.CUSTOM_RUN_UID_PROPERTY_NAME; import static io.testomat.core.constants.PropertyNameConstants.RUN_TITLE_PROPERTY_NAME; +import io.testomat.core.artifact.util.ArtifactKeyGenerator; import io.testomat.core.batch.BatchResultManager; import io.testomat.core.client.ApiInterface; import io.testomat.core.client.ClientFactory; @@ -59,29 +60,31 @@ public synchronized void initializeIfNeeded() { try { ClientFactory clientFactory = TestomatClientFactory.getClientFactory(); log.debug("Client factory initialized successfully"); + ApiInterface client = clientFactory.createClient(); log.debug("Client created successfully"); - String uid = getCustomRunUid(client); - if (uid != null) { - log.debug("Custom uid = {}", uid); - } else { - log.debug("Custom uid is not provided"); - } + + String uid = defineRunId(client); + log.debug("Uid defined successfully: {}", uid); + + ArtifactKeyGenerator.initializeRunId(uid); + log.debug("ArtifactKeyGenerator received uid: {}", uid); apiClient.set(client); log.debug("Api client is set"); + runUid.set(uid); - log.debug("Run ID is set: {}", runUid); batchManager.set(new BatchResultManager(client, uid)); log.debug("Batch manager is set"); + startTime = System.currentTimeMillis(); log.debug("Start time = {}", startTime); registerShutdownHook(); log.debug("Shutdown hook registered"); - log.debug("Global test run initialized with UID: {}", uid); + log.debug("Global run manger initialized with UID: {}", uid); } catch (Exception e) { log.error("Failed to initialize test run: {}", String.valueOf(e)); } @@ -145,6 +148,7 @@ public boolean isActive() { * Calculates run duration and sends completion notification to Testomat.io. */ private void finalizeRun() { + apiClient.get().uploadLinksToTestomatio(runUid.get()); BatchResultManager manager = batchManager.getAndSet(null); if (manager != null) { manager.shutdown(); @@ -174,7 +178,7 @@ private String getRunTitle() { .getPropertyProvider().getProperty(RUN_TITLE_PROPERTY_NAME); } - private String getCustomRunUid(ApiInterface client) throws IOException { + private String defineRunId(ApiInterface client) throws IOException { String customUid; try { customUid = provider.getProperty(CUSTOM_RUN_UID_PROPERTY_NAME); diff --git a/java-reporter-core/src/test/java/io/testomat/core/client/NativeApiClientTest.java b/java-reporter-core/src/test/java/io/testomat/core/client/TestomatioClientTest.java similarity index 91% rename from java-reporter-core/src/test/java/io/testomat/core/client/NativeApiClientTest.java rename to java-reporter-core/src/test/java/io/testomat/core/client/TestomatioClientTest.java index 8344acd..7e8ec3f 100644 --- a/java-reporter-core/src/test/java/io/testomat/core/client/NativeApiClientTest.java +++ b/java-reporter-core/src/test/java/io/testomat/core/client/TestomatioClientTest.java @@ -1,6 +1,7 @@ package io.testomat.core.client; -import static org.junit.jupiter.api.Assertions.*; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertThrows; import io.testomat.core.client.http.CustomHttpClient; import io.testomat.core.client.request.NativeRequestBodyBuilder; @@ -16,27 +17,27 @@ @ExtendWith(MockitoExtension.class) @DisplayName("NativeApiClient Tests") -class NativeApiClientTest { +class TestomatioClientTest { @Mock private CustomHttpClient mockHttpClient; - + @Mock private NativeRequestBodyBuilder mockRequestBodyBuilder; - private NativeApiClient apiClient; + private TestomatioClient apiClient; private final String apiKey = "test-api-key"; @BeforeEach void setUp() { - apiClient = new NativeApiClient(apiKey, mockHttpClient, mockRequestBodyBuilder); + apiClient = new TestomatioClient(apiKey, mockHttpClient, mockRequestBodyBuilder); } @Test @DisplayName("Should handle createRun method call") void createRun_ValidTitle_ShouldHandleCall() { String title = "Test Run Title"; - + // Test will fail due to real UrlBuilder trying to load properties // Verifying the method handles calls gracefully assertThrows(Exception.class, () -> apiClient.createRun(title)); @@ -87,7 +88,7 @@ void reportTests_NullResults_ShouldSkipReporting() { void finishTestRun_ValidInput_ShouldHandleCall() { String uid = "test-run-123"; float duration = 45.5f; - + // Test will fail due to property loading, verifying graceful handling assertThrows(Exception.class, () -> apiClient.finishTestRun(uid, duration)); } diff --git a/java-reporter-junit/src/main/java/io/testomat/junit/listener/JunitListener.java b/java-reporter-junit/src/main/java/io/testomat/junit/listener/JunitListener.java index b27d0ff..0032bc3 100644 --- a/java-reporter-junit/src/main/java/io/testomat/junit/listener/JunitListener.java +++ b/java-reporter-junit/src/main/java/io/testomat/junit/listener/JunitListener.java @@ -4,7 +4,6 @@ import static io.testomat.core.constants.CommonConstants.PASSED; import static io.testomat.core.constants.CommonConstants.SKIPPED; -import io.testomat.core.artifact.TemporalArtifactStorage; import io.testomat.core.artifact.client.AwsService; import io.testomat.core.propertyconfig.impl.PropertyProviderFactoryImpl; import io.testomat.core.propertyconfig.interf.PropertyProvider; @@ -54,9 +53,9 @@ public JunitListener() { * Constructor for testing * * @param methodExportManager the method export manager - * @param runManager the global run manager - * @param reporter the JUnit test reporter - * @param provider the property provider + * @param runManager the global run manager + * @param reporter the JUnit test reporter + * @param provider the property provider */ public JunitListener(MethodExportManager methodExportManager, GlobalRunManager runManager, @@ -160,8 +159,7 @@ private boolean isListeningRequired() { } @Override - public void afterEach(ExtensionContext context) throws Exception { + public void afterEach(ExtensionContext context) { awsService.uploadAllArtifactsForTest(context.getDisplayName(), context.getUniqueId()); - TemporalArtifactStorage.cleanup(); } } From b470b67680d52648a4302e28aa630fe71739d4f3 Mon Sep 17 00:00:00 2001 From: Yevhenii Vlasenko Date: Tue, 16 Sep 2025 22:40:44 +0300 Subject: [PATCH 10/44] removed s3presigner usage --- .../io/testomat/core/artifact/util/ArtifactUrlGenerator.java | 3 --- 1 file changed, 3 deletions(-) diff --git a/java-reporter-core/src/main/java/io/testomat/core/artifact/util/ArtifactUrlGenerator.java b/java-reporter-core/src/main/java/io/testomat/core/artifact/util/ArtifactUrlGenerator.java index bb63c98..e195356 100644 --- a/java-reporter-core/src/main/java/io/testomat/core/artifact/util/ArtifactUrlGenerator.java +++ b/java-reporter-core/src/main/java/io/testomat/core/artifact/util/ArtifactUrlGenerator.java @@ -6,17 +6,14 @@ public class ArtifactUrlGenerator { private final AwsClient awsClient; - private final S3Presigner s3Presigner; public ArtifactUrlGenerator() { this.awsClient = new AwsClient(); - this.s3Presigner = S3Presigner.create(); } public ArtifactUrlGenerator(AwsClient awsClient, S3Presigner s3Presigner) { this.awsClient = awsClient; - this.s3Presigner = s3Presigner; } public String generateUrl(String bucket, String key) { From 4c153bf41c0178f7476afa586968496bce25052c Mon Sep 17 00:00:00 2001 From: Yevhenii Vlasenko Date: Wed, 17 Sep 2025 09:58:51 +0300 Subject: [PATCH 11/44] Link report body fix(junit) --- .../core/artifact/LinkUploadBodyBuilder.java | 18 +++++++++++------- .../testomat/core/client/TestomatioClient.java | 2 +- .../JUnitTestResultConstructor.java | 3 ++- 3 files changed, 14 insertions(+), 9 deletions(-) diff --git a/java-reporter-core/src/main/java/io/testomat/core/artifact/LinkUploadBodyBuilder.java b/java-reporter-core/src/main/java/io/testomat/core/artifact/LinkUploadBodyBuilder.java index f5dad78..1294987 100644 --- a/java-reporter-core/src/main/java/io/testomat/core/artifact/LinkUploadBodyBuilder.java +++ b/java-reporter-core/src/main/java/io/testomat/core/artifact/LinkUploadBodyBuilder.java @@ -12,20 +12,24 @@ public class LinkUploadBodyBuilder { private static final Logger log = LoggerFactory.getLogger(LinkUploadBodyBuilder.class); - public String buildLinkUploadRequestBody(Map> map) { + public String buildLinkUploadRequestBody(Map> map, String apiKey) { ObjectMapper mapper = new ObjectMapper(); - ArrayNode arrayNode = mapper.createArrayNode(); + ObjectNode rootNode = mapper.createObjectNode(); + ArrayNode testsArray = mapper.createArrayNode(); for (Map.Entry> entry : map.entrySet()) { - ObjectNode objectNode = mapper.createObjectNode(); - objectNode.put("rid", entry.getKey()); - objectNode.set("artifacts", mapper.valueToTree(entry.getValue())); - arrayNode.add(objectNode); + ObjectNode testNode = mapper.createObjectNode(); + testNode.put("rid", entry.getKey()); + testNode.set("artifacts", mapper.valueToTree(entry.getValue())); + testsArray.add(testNode); } + rootNode.put("api_key", apiKey); + rootNode.set("tests", testsArray); + String json = null; try { - json = mapper.writeValueAsString(arrayNode); + json = mapper.writeValueAsString(rootNode); } catch (JsonProcessingException e) { log.warn("Failed to convert convert link storage to json body"); } diff --git a/java-reporter-core/src/main/java/io/testomat/core/client/TestomatioClient.java b/java-reporter-core/src/main/java/io/testomat/core/client/TestomatioClient.java index 1bc0609..41e7d98 100644 --- a/java-reporter-core/src/main/java/io/testomat/core/client/TestomatioClient.java +++ b/java-reporter-core/src/main/java/io/testomat/core/client/TestomatioClient.java @@ -130,7 +130,7 @@ public void finishTestRun(String uid, float duration) { public void uploadLinksToTestomatio(String uid) { - String requestBody = linkUploadBodyBuilder.buildLinkUploadRequestBody(UploadedArtifactLinksStorage.getLinkStorage()); + String requestBody = linkUploadBodyBuilder.buildLinkUploadRequestBody(UploadedArtifactLinksStorage.getLinkStorage(), apiKey); if (UploadedArtifactLinksStorage.getLinkStorage().isEmpty()) { return; } diff --git a/java-reporter-junit/src/main/java/io/testomat/junit/constructor/JUnitTestResultConstructor.java b/java-reporter-junit/src/main/java/io/testomat/junit/constructor/JUnitTestResultConstructor.java index 6722e12..970f05a 100644 --- a/java-reporter-junit/src/main/java/io/testomat/junit/constructor/JUnitTestResultConstructor.java +++ b/java-reporter-junit/src/main/java/io/testomat/junit/constructor/JUnitTestResultConstructor.java @@ -74,7 +74,8 @@ public TestResult constructTestRunResult(TestMetadata metadata, String stack; Object example = null; - String rid = null; + String rid = context.getUniqueId(); + log.debug("-> RID = {}", rid); if (metaDataExtractor.isParameterizedTest(context)) { example = metaDataExtractor.extractTestParameters(context); From d031136957bf27b737db97d195866e49e83a1f2b Mon Sep 17 00:00:00 2001 From: Yevhenii Vlasenko Date: Wed, 17 Sep 2025 19:07:09 +0300 Subject: [PATCH 12/44] checkpoint --- .../io/testomat/core/ArtifactLinkData.java | 50 +++++++++++++++++++ .../core/ArtifactLinkDataStorage.java | 9 ++++ .../core/artifact/LinkUploadBodyBuilder.java | 35 +++++++++++-- .../core/artifact/client/AwsService.java | 25 +++++++++- .../core/client/TestomatioClient.java | 13 +++-- .../extractor/JunitMetaDataExtractor.java | 2 +- .../junit/listener/JunitListener.java | 4 +- 7 files changed, 126 insertions(+), 12 deletions(-) create mode 100644 java-reporter-core/src/main/java/io/testomat/core/ArtifactLinkData.java create mode 100644 java-reporter-core/src/main/java/io/testomat/core/ArtifactLinkDataStorage.java diff --git a/java-reporter-core/src/main/java/io/testomat/core/ArtifactLinkData.java b/java-reporter-core/src/main/java/io/testomat/core/ArtifactLinkData.java new file mode 100644 index 0000000..661053f --- /dev/null +++ b/java-reporter-core/src/main/java/io/testomat/core/ArtifactLinkData.java @@ -0,0 +1,50 @@ +package io.testomat.core; + +import java.util.List; + +public class ArtifactLinkData { + private String rid; + private String testId; + private String testName; + + private List links; + + public ArtifactLinkData(String testName, String rid, String testId, List links) { + this.testName = testName; + this.rid = rid; + this.testId = testId; + this.links = links; + } + + public List getLinks() { + return links; + } + + public void setLinks(List links) { + this.links = links; + } + + public String getTestId() { + return testId; + } + + public void setTestId(String testId) { + this.testId = testId; + } + + public String getRid() { + return rid; + } + + public void setRid(String rid) { + this.rid = rid; + } + + public String getTestName() { + return testName; + } + + public void setTestName(String testName) { + this.testName = testName; + } +} diff --git a/java-reporter-core/src/main/java/io/testomat/core/ArtifactLinkDataStorage.java b/java-reporter-core/src/main/java/io/testomat/core/ArtifactLinkDataStorage.java new file mode 100644 index 0000000..a0ac550 --- /dev/null +++ b/java-reporter-core/src/main/java/io/testomat/core/ArtifactLinkDataStorage.java @@ -0,0 +1,9 @@ +package io.testomat.core; + +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; + +public class ArtifactLinkDataStorage { + public static final List ARTEFACT_LINK_DATA_STORAGE = + new CopyOnWriteArrayList<>(); +} diff --git a/java-reporter-core/src/main/java/io/testomat/core/artifact/LinkUploadBodyBuilder.java b/java-reporter-core/src/main/java/io/testomat/core/artifact/LinkUploadBodyBuilder.java index 1294987..a1a54d7 100644 --- a/java-reporter-core/src/main/java/io/testomat/core/artifact/LinkUploadBodyBuilder.java +++ b/java-reporter-core/src/main/java/io/testomat/core/artifact/LinkUploadBodyBuilder.java @@ -4,6 +4,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.node.ArrayNode; import com.fasterxml.jackson.databind.node.ObjectNode; +import io.testomat.core.ArtifactLinkData; import java.util.List; import java.util.Map; import org.slf4j.Logger; @@ -12,15 +13,41 @@ public class LinkUploadBodyBuilder { private static final Logger log = LoggerFactory.getLogger(LinkUploadBodyBuilder.class); - public String buildLinkUploadRequestBody(Map> map, String apiKey) { + // public String buildLinkUploadRequestBody(Map> map, String apiKey) { + // ObjectMapper mapper = new ObjectMapper(); + // ObjectNode rootNode = mapper.createObjectNode(); + // ArrayNode testsArray = mapper.createArrayNode(); + // + // for (Map.Entry> entry : map.entrySet()) { + // ObjectNode testNode = mapper.createObjectNode(); + // testNode.put("rid", entry.getKey()); + // testNode.set("artifacts", mapper.valueToTree(entry.getValue())); + // testsArray.add(testNode); + // } + // + // rootNode.put("api_key", apiKey); + // rootNode.set("tests", testsArray); + // + // String json = null; + // try { + // json = mapper.writeValueAsString(rootNode); + // } catch (JsonProcessingException e) { + // log.warn("Failed to convert convert link storage to json body"); + // } + // return json; + // } + + public String buildLinkUploadRequestBody(List storedLinkData, String apiKey) { ObjectMapper mapper = new ObjectMapper(); ObjectNode rootNode = mapper.createObjectNode(); ArrayNode testsArray = mapper.createArrayNode(); - for (Map.Entry> entry : map.entrySet()) { + for (ArtifactLinkData data : storedLinkData) { ObjectNode testNode = mapper.createObjectNode(); - testNode.put("rid", entry.getKey()); - testNode.set("artifacts", mapper.valueToTree(entry.getValue())); + testNode.put("rid", data.getRid()); + testNode.put("test_id", data.getTestId()); + testNode.put("title", data.getTestName()); + testNode.set("artifacts", mapper.valueToTree(data.getLinks())); testsArray.add(testNode); } diff --git a/java-reporter-core/src/main/java/io/testomat/core/artifact/client/AwsService.java b/java-reporter-core/src/main/java/io/testomat/core/artifact/client/AwsService.java index 0781d66..1a37bdc 100644 --- a/java-reporter-core/src/main/java/io/testomat/core/artifact/client/AwsService.java +++ b/java-reporter-core/src/main/java/io/testomat/core/artifact/client/AwsService.java @@ -1,5 +1,7 @@ package io.testomat.core.artifact.client; +import io.testomat.core.ArtifactLinkData; +import io.testomat.core.ArtifactLinkDataStorage; import io.testomat.core.artifact.TempArtifactDirectoriesStorage; import io.testomat.core.artifact.UploadedArtifactLinksStorage; import io.testomat.core.artifact.credential.CredentialsManager; @@ -39,7 +41,25 @@ public AwsService(ArtifactKeyGenerator keyGenerator, AwsClient awsClient, log.debug("AWS Service initialized"); } - public void uploadAllArtifactsForTest(String testName, String rid) { +// public void uploadAllArtifactsForTest(String testName, String rid) { +// if (TempArtifactDirectoriesStorage.DIRECTORIES.get().isEmpty()) { +// log.debug("Artifact list is empty for test: {}", testName); +// return; +// } +// +// S3Credentials credentials = CredentialsManager.getCredentials(); +// List uploadedArtifactsLinks = new ArrayList<>(); +// +// for (String dir : TempArtifactDirectoriesStorage.DIRECTORIES.get()) { +// String key = keyGenerator.generateKey(dir, rid, testName); +// uploadArtifact(dir, key, credentials); +// uploadedArtifactsLinks.add(urlGenerator.generateUrl(credentials.getBucket(), key)); +// } +// +// UploadedArtifactLinksStorage.store(rid, uploadedArtifactsLinks); +// } + + public void uploadAllArtifactsForTest(String testName, String rid, String testId) { if (TempArtifactDirectoriesStorage.DIRECTORIES.get().isEmpty()) { log.debug("Artifact list is empty for test: {}", testName); return; @@ -54,7 +74,8 @@ public void uploadAllArtifactsForTest(String testName, String rid) { uploadedArtifactsLinks.add(urlGenerator.generateUrl(credentials.getBucket(), key)); } - UploadedArtifactLinksStorage.store(rid, uploadedArtifactsLinks); + // UploadedArtifactLinksStorage.store(rid, uploadedArtifactsLinks); + ArtifactLinkDataStorage.ARTEFACT_LINK_DATA_STORAGE.add(new ArtifactLinkData(testName, rid,testId, uploadedArtifactsLinks)); } private void uploadArtifact(String dir, String key, S3Credentials credentials) { diff --git a/java-reporter-core/src/main/java/io/testomat/core/client/TestomatioClient.java b/java-reporter-core/src/main/java/io/testomat/core/client/TestomatioClient.java index 41e7d98..d4fe237 100644 --- a/java-reporter-core/src/main/java/io/testomat/core/client/TestomatioClient.java +++ b/java-reporter-core/src/main/java/io/testomat/core/client/TestomatioClient.java @@ -3,6 +3,7 @@ import static io.testomat.core.constants.CommonConstants.REPORTER_VERSION; import static io.testomat.core.constants.CommonConstants.RESPONSE_UID_KEY; +import io.testomat.core.ArtifactLinkDataStorage; import io.testomat.core.artifact.LinkUploadBodyBuilder; import io.testomat.core.artifact.UploadedArtifactLinksStorage; import io.testomat.core.artifact.credential.CredentialsManager; @@ -130,11 +131,15 @@ public void finishTestRun(String uid, float duration) { public void uploadLinksToTestomatio(String uid) { - String requestBody = linkUploadBodyBuilder.buildLinkUploadRequestBody(UploadedArtifactLinksStorage.getLinkStorage(), apiKey); - if (UploadedArtifactLinksStorage.getLinkStorage().isEmpty()) { - return; - } + // String requestBody = linkUploadBodyBuilder.buildLinkUploadRequestBody( + // UploadedArtifactLinksStorage.getLinkStorage(), apiKey); + String requestBody = linkUploadBodyBuilder.buildLinkUploadRequestBody( + ArtifactLinkDataStorage.ARTEFACT_LINK_DATA_STORAGE, apiKey); + // if (UploadedArtifactLinksStorage.getLinkStorage().isEmpty()) { + // return; + // } String url = urlBuilder.buildReportTestUrl(uid); + log.debug("-> REQUEST BODY: {}", requestBody); try { client.post(url, requestBody, null); diff --git a/java-reporter-junit/src/main/java/io/testomat/junit/extractor/JunitMetaDataExtractor.java b/java-reporter-junit/src/main/java/io/testomat/junit/extractor/JunitMetaDataExtractor.java index 39d198b..aab2be9 100644 --- a/java-reporter-junit/src/main/java/io/testomat/junit/extractor/JunitMetaDataExtractor.java +++ b/java-reporter-junit/src/main/java/io/testomat/junit/extractor/JunitMetaDataExtractor.java @@ -129,7 +129,7 @@ private String extractTitleFromAnnotation(Method method) { return annotation != null ? annotation.value() : method.getName(); } - private String extractTestId(Method method) { + public static String extractTestId(Method method) { TestId annotation = method.getAnnotation(TestId.class); return annotation != null ? annotation.value() : null; } diff --git a/java-reporter-junit/src/main/java/io/testomat/junit/listener/JunitListener.java b/java-reporter-junit/src/main/java/io/testomat/junit/listener/JunitListener.java index 0032bc3..0c1652f 100644 --- a/java-reporter-junit/src/main/java/io/testomat/junit/listener/JunitListener.java +++ b/java-reporter-junit/src/main/java/io/testomat/junit/listener/JunitListener.java @@ -8,6 +8,7 @@ import io.testomat.core.propertyconfig.impl.PropertyProviderFactoryImpl; import io.testomat.core.propertyconfig.interf.PropertyProvider; import io.testomat.core.runmanager.GlobalRunManager; +import io.testomat.junit.extractor.JunitMetaDataExtractor; import io.testomat.junit.methodexporter.MethodExportManager; import io.testomat.junit.reporter.JunitTestReporter; import java.util.Optional; @@ -160,6 +161,7 @@ private boolean isListeningRequired() { @Override public void afterEach(ExtensionContext context) { - awsService.uploadAllArtifactsForTest(context.getDisplayName(), context.getUniqueId()); + awsService.uploadAllArtifactsForTest(context.getDisplayName(), context.getUniqueId(), + JunitMetaDataExtractor.extractTestId(context.getTestMethod().get())); } } From 9b7260008771da8511aebf5af42ed975c4c8a4ee Mon Sep 17 00:00:00 2001 From: Yevhenii Vlasenko Date: Thu, 18 Sep 2025 11:40:54 +0300 Subject: [PATCH 13/44] implemented secondary tests sending with artifacts --- .../io/testomat/core/ReportedTestStorage.java | 25 +++++++++++++++++++ .../io/testomat/core/client/ApiInterface.java | 2 ++ .../core/client/TestomatioClient.java | 15 ++++++++++- .../request/NativeRequestBodyBuilder.java | 13 +++++++++- .../client/request/RequestBodyBuilder.java | 6 ++++- .../core/runmanager/GlobalRunManager.java | 11 +++++--- 6 files changed, 66 insertions(+), 6 deletions(-) create mode 100644 java-reporter-core/src/main/java/io/testomat/core/ReportedTestStorage.java diff --git a/java-reporter-core/src/main/java/io/testomat/core/ReportedTestStorage.java b/java-reporter-core/src/main/java/io/testomat/core/ReportedTestStorage.java new file mode 100644 index 0000000..b7b04c2 --- /dev/null +++ b/java-reporter-core/src/main/java/io/testomat/core/ReportedTestStorage.java @@ -0,0 +1,25 @@ +package io.testomat.core; + +import java.util.List; +import java.util.Map; +import java.util.concurrent.CopyOnWriteArrayList; + +public class ReportedTestStorage { + private static final List> STORAGE = new CopyOnWriteArrayList<>(); + + public static void store(Map body) { + STORAGE.add(body); + } + + public static List> getStorage() { + return STORAGE; + } + + public static void linkArtifactsToTests(List artifactLinkData) { + for (ArtifactLinkData data : artifactLinkData) { + STORAGE.stream() + .filter(body -> data.getRid().equals(body.get("rid"))) + .forEach(body -> body.put("artifacts", data.getLinks())); + } + } +} diff --git a/java-reporter-core/src/main/java/io/testomat/core/client/ApiInterface.java b/java-reporter-core/src/main/java/io/testomat/core/client/ApiInterface.java index 38c9c7e..6da1bed 100644 --- a/java-reporter-core/src/main/java/io/testomat/core/client/ApiInterface.java +++ b/java-reporter-core/src/main/java/io/testomat/core/client/ApiInterface.java @@ -39,6 +39,8 @@ public interface ApiInterface { */ void reportTests(String uid, List results) throws IOException; + void sendTestWithArtifacts(String uid); + /** * Finalizes the test run and marks it as completed. * diff --git a/java-reporter-core/src/main/java/io/testomat/core/client/TestomatioClient.java b/java-reporter-core/src/main/java/io/testomat/core/client/TestomatioClient.java index d4fe237..0d5a687 100644 --- a/java-reporter-core/src/main/java/io/testomat/core/client/TestomatioClient.java +++ b/java-reporter-core/src/main/java/io/testomat/core/client/TestomatioClient.java @@ -4,8 +4,8 @@ import static io.testomat.core.constants.CommonConstants.RESPONSE_UID_KEY; import io.testomat.core.ArtifactLinkDataStorage; +import io.testomat.core.ReportedTestStorage; import io.testomat.core.artifact.LinkUploadBodyBuilder; -import io.testomat.core.artifact.UploadedArtifactLinksStorage; import io.testomat.core.artifact.credential.CredentialsManager; import io.testomat.core.client.http.CustomHttpClient; import io.testomat.core.client.request.NativeRequestBodyBuilder; @@ -114,6 +114,19 @@ public void reportTests(String uid, List results) { } } + @Override + public void sendTestWithArtifacts(String uid) { + log.debug("Preparing to secondary batch sending with artifacts"); + String url = urlBuilder.buildReportTestUrl(uid); + try { + String requestBody = requestBodyBuilder.buildBatchReportBodyWithArtifacts(ReportedTestStorage.getStorage(), apiKey); + client.post(url, requestBody, null); + log.debug("Sent requestBody: {}", requestBody); + } catch (Exception e) { + log.error("Failed while secondary batch sending with artifacts"); + } + } + @Override public void finishTestRun(String uid, float duration) { try { diff --git a/java-reporter-core/src/main/java/io/testomat/core/client/request/NativeRequestBodyBuilder.java b/java-reporter-core/src/main/java/io/testomat/core/client/request/NativeRequestBodyBuilder.java index c5a8b7c..eda4c4d 100644 --- a/java-reporter-core/src/main/java/io/testomat/core/client/request/NativeRequestBodyBuilder.java +++ b/java-reporter-core/src/main/java/io/testomat/core/client/request/NativeRequestBodyBuilder.java @@ -11,6 +11,7 @@ import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; +import io.testomat.core.ReportedTestStorage; import io.testomat.core.constants.ApiRequestFields; import io.testomat.core.exception.FailedToCreateRunBodyException; import io.testomat.core.model.TestResult; @@ -97,6 +98,16 @@ public String buildBatchTestReportBody(List results, String apiKey) return objectMapper.writeValueAsString(requestBody); } + @Override + public String buildBatchReportBodyWithArtifacts(List> testsWithArtifacts, + String apiKey) throws JsonProcessingException { + Map requestBody = new HashMap<>(); + requestBody.put(API_KEY_STRING, apiKey); + requestBody.put(TESTS_STRING, testsWithArtifacts); + + return objectMapper.writeValueAsString(requestBody); + } + @Override public String buildFinishRunBody(float duration) throws JsonProcessingException { Map body = Map.of( @@ -146,7 +157,7 @@ private Map buildTestResultMap(TestResult result) { if (createParam) { body.put("create", TRUE); } - + ReportedTestStorage.store(body); return body; } diff --git a/java-reporter-core/src/main/java/io/testomat/core/client/request/RequestBodyBuilder.java b/java-reporter-core/src/main/java/io/testomat/core/client/request/RequestBodyBuilder.java index 6040593..63deab5 100644 --- a/java-reporter-core/src/main/java/io/testomat/core/client/request/RequestBodyBuilder.java +++ b/java-reporter-core/src/main/java/io/testomat/core/client/request/RequestBodyBuilder.java @@ -3,6 +3,7 @@ import com.fasterxml.jackson.core.JsonProcessingException; import io.testomat.core.model.TestResult; import java.util.List; +import java.util.Map; /** * Strategy interface for building JSON request bodies for Testomat.io API operations. @@ -32,7 +33,7 @@ public interface RequestBodyBuilder { * Creates JSON request body for batch test result reporting. * * @param results collection of test execution results - * @param apiKey API key for authentication in batch requests + * @param apiKey API key for authentication in batch requests * @return JSON-formatted request body string * @throws JsonProcessingException if JSON serialization fails */ @@ -49,4 +50,7 @@ String buildBatchTestReportBody(List results, String apiKey) String buildFinishRunBody(float duration) throws JsonProcessingException; String buildUploadLinksBody(String jsonString); + + String buildBatchReportBodyWithArtifacts(List> testsWithArtifacts, + String apiKey) throws JsonProcessingException; } diff --git a/java-reporter-core/src/main/java/io/testomat/core/runmanager/GlobalRunManager.java b/java-reporter-core/src/main/java/io/testomat/core/runmanager/GlobalRunManager.java index 470aa04..c5df051 100644 --- a/java-reporter-core/src/main/java/io/testomat/core/runmanager/GlobalRunManager.java +++ b/java-reporter-core/src/main/java/io/testomat/core/runmanager/GlobalRunManager.java @@ -3,6 +3,8 @@ import static io.testomat.core.constants.PropertyNameConstants.CUSTOM_RUN_UID_PROPERTY_NAME; import static io.testomat.core.constants.PropertyNameConstants.RUN_TITLE_PROPERTY_NAME; +import io.testomat.core.ArtifactLinkDataStorage; +import io.testomat.core.ReportedTestStorage; import io.testomat.core.artifact.util.ArtifactKeyGenerator; import io.testomat.core.batch.BatchResultManager; import io.testomat.core.client.ApiInterface; @@ -148,20 +150,23 @@ public boolean isActive() { * Calculates run duration and sends completion notification to Testomat.io. */ private void finalizeRun() { - apiClient.get().uploadLinksToTestomatio(runUid.get()); + // apiClient.get().uploadLinksToTestomatio(runUid.get()); BatchResultManager manager = batchManager.getAndSet(null); if (manager != null) { manager.shutdown(); } - String uid = runUid.getAndSet(null); - ApiInterface client = apiClient.getAndSet(null); + String uid = runUid.get(); + ApiInterface client = apiClient.get(); if (uid != null && client != null) { try { float duration = (System.currentTimeMillis() - startTime) / 1000.0f; client.finishTestRun(uid, duration); log.debug("Test run finished: {}", uid); + ReportedTestStorage.linkArtifactsToTests(ArtifactLinkDataStorage.ARTEFACT_LINK_DATA_STORAGE); + + apiClient.get().sendTestWithArtifacts(runUid.get()); } catch (IOException e) { log.error("Failed to finish test run{}", String.valueOf(e.getCause())); } From 60d5c3531f85536d40808e50d1c873ebde1defb6 Mon Sep 17 00:00:00 2001 From: Yevhenii Vlasenko Date: Thu, 18 Sep 2025 15:11:03 +0300 Subject: [PATCH 14/44] implemented testomatio.disable.artifacts (junit) --- .../core/artifact/client/AwsClient.java | 3 +-- .../core/constants/ArtifactPropertyNames.java | 2 +- .../junit/listener/JunitListener.java | 24 +++++++++++++++++-- 3 files changed, 24 insertions(+), 5 deletions(-) diff --git a/java-reporter-core/src/main/java/io/testomat/core/artifact/client/AwsClient.java b/java-reporter-core/src/main/java/io/testomat/core/artifact/client/AwsClient.java index 282bc8d..b694007 100644 --- a/java-reporter-core/src/main/java/io/testomat/core/artifact/client/AwsClient.java +++ b/java-reporter-core/src/main/java/io/testomat/core/artifact/client/AwsClient.java @@ -43,8 +43,7 @@ private S3Client initialize() { S3Credentials s3Credentials = CredentialsManager.getCredentials(); if (!validationService.areCredentialsValid(s3Credentials)) { - log.error("S3 credentials validation failed during client initialization"); - throw new ArtifactManagementException("Invalid S3 credentials provided"); + log.error("S3 credentials validation failed during client initialization. The artifacts won't be handled"); } log.debug("Creating S3 client for region: {}, bucket: {}", diff --git a/java-reporter-core/src/main/java/io/testomat/core/constants/ArtifactPropertyNames.java b/java-reporter-core/src/main/java/io/testomat/core/constants/ArtifactPropertyNames.java index 533c02d..77f4aa9 100644 --- a/java-reporter-core/src/main/java/io/testomat/core/constants/ArtifactPropertyNames.java +++ b/java-reporter-core/src/main/java/io/testomat/core/constants/ArtifactPropertyNames.java @@ -1,6 +1,7 @@ package io.testomat.core.constants; public class ArtifactPropertyNames { + public static final String ARTIFACT_DISABLE_PROPERTY_NAME = "testomatio.disable.artifacts"; public static final String BUCKET_PROPERTY_NAME = "s3.bucket"; public static final String ACCESS_KEY_PROPERTY_NAME = "s3.access.key.id"; public static final String SECRET_ACCESS_KEY_PROPERTY_NAME = " s3.secret.access.key.id"; @@ -9,7 +10,6 @@ public class ArtifactPropertyNames { public static final String SESSION_TOKEN_PROPERTY_NAME = "s3.session.token"; public static final String FORCE_PATH_PROPERTY_NAME = "s3.force.path.style"; - public static final String ARTIFACT_DISABLE_PROPERTY_NAME = "testomatio.disable.artifacts"; public static final String PRIVATE_ARTIFACTS_PROPERTY_NAME = "testomatio.private.artifacts"; public static final String MAX_SIZE_ARTIFACTS_PROPERTY_NAME = "testomatio.artifact.max.size"; diff --git a/java-reporter-junit/src/main/java/io/testomat/junit/listener/JunitListener.java b/java-reporter-junit/src/main/java/io/testomat/junit/listener/JunitListener.java index 0c1652f..0a7f229 100644 --- a/java-reporter-junit/src/main/java/io/testomat/junit/listener/JunitListener.java +++ b/java-reporter-junit/src/main/java/io/testomat/junit/listener/JunitListener.java @@ -1,5 +1,6 @@ package io.testomat.junit.listener; +import static io.testomat.core.constants.ArtifactPropertyNames.ARTIFACT_DISABLE_PROPERTY_NAME; import static io.testomat.core.constants.CommonConstants.FAILED; import static io.testomat.core.constants.CommonConstants.PASSED; import static io.testomat.core.constants.CommonConstants.SKIPPED; @@ -32,6 +33,7 @@ public class JunitListener implements BeforeEachCallback, BeforeAllCallback, private static final Logger log = LoggerFactory.getLogger(JunitListener.class); private static final String LISTENING_REQUIRED_PROPERTY_NAME = "testomatio.listening"; + private Boolean artifactEnabled; private final MethodExportManager methodExportManager; private final GlobalRunManager runManager; @@ -48,6 +50,7 @@ public JunitListener() { this.awsService = new AwsService(); this.provider = PropertyProviderFactoryImpl.getPropertyProviderFactory().getPropertyProvider(); + this.artifactEnabled = defineArtifactsDisabled(); } /** @@ -161,7 +164,24 @@ private boolean isListeningRequired() { @Override public void afterEach(ExtensionContext context) { - awsService.uploadAllArtifactsForTest(context.getDisplayName(), context.getUniqueId(), - JunitMetaDataExtractor.extractTestId(context.getTestMethod().get())); + if (!artifactEnabled) { + awsService.uploadAllArtifactsForTest(context.getDisplayName(), context.getUniqueId(), + JunitMetaDataExtractor.extractTestId(context.getTestMethod().get())); + } + } + + private boolean defineArtifactsDisabled() { + boolean result; + String property; + try { + property = provider.getProperty(ARTIFACT_DISABLE_PROPERTY_NAME); + result = property != null + && !property.trim().isEmpty() + && !property.equalsIgnoreCase("0"); + + } catch (Exception e) { + return false; + } + return result; } } From 6125ccddfcc4f7c1dce133b80627196a98fbd798 Mon Sep 17 00:00:00 2001 From: Yevhenii Vlasenko Date: Thu, 18 Sep 2025 16:18:53 +0300 Subject: [PATCH 15/44] implemented env properties logic: ARTIFACT_DISABLE_PROPERTY_NAME, BUCKET_PROPERTY_NAME, ACCESS_KEY_PROPERTY_NAME, SECRET_ACCESS_KEY_PROPERTY_NAME, REGION_PROPERTY_NAME --- .../credential/CredentialsManager.java | 121 ++++++++++++++---- .../core/client/TestomatioClient.java | 2 +- .../core/constants/CredentialConstants.java | 14 +- 3 files changed, 103 insertions(+), 34 deletions(-) diff --git a/java-reporter-core/src/main/java/io/testomat/core/artifact/credential/CredentialsManager.java b/java-reporter-core/src/main/java/io/testomat/core/artifact/credential/CredentialsManager.java index bfb0f4e..481ca4c 100644 --- a/java-reporter-core/src/main/java/io/testomat/core/artifact/credential/CredentialsManager.java +++ b/java-reporter-core/src/main/java/io/testomat/core/artifact/credential/CredentialsManager.java @@ -1,13 +1,21 @@ package io.testomat.core.artifact.credential; -import static io.testomat.core.constants.CredentialConstants.ACCESS_KEY_PROPERTY_NAME; -import static io.testomat.core.constants.CredentialConstants.BUCKET_PROPERTY_NAME; -import static io.testomat.core.constants.CredentialConstants.IAM_PROPERTY_NAME; -import static io.testomat.core.constants.CredentialConstants.PRESIGN_PROPERTY_NAME; -import static io.testomat.core.constants.CredentialConstants.REGION_PROPERTY_NAME; -import static io.testomat.core.constants.CredentialConstants.SECRET_KEY_PROPERTY_NAME; -import static io.testomat.core.constants.CredentialConstants.SHARED_PROPERTY_NAME; - +import static io.testomat.core.constants.ArtifactPropertyNames.ACCESS_KEY_PROPERTY_NAME; +import static io.testomat.core.constants.ArtifactPropertyNames.BUCKET_PROPERTY_NAME; +import static io.testomat.core.constants.ArtifactPropertyNames.REGION_PROPERTY_NAME; +import static io.testomat.core.constants.ArtifactPropertyNames.SECRET_ACCESS_KEY_PROPERTY_NAME; +import static io.testomat.core.constants.CredentialConstants.ACCESS_KEY_ID; +import static io.testomat.core.constants.CredentialConstants.BUCKET; +import static io.testomat.core.constants.CredentialConstants.IAM; +import static io.testomat.core.constants.CredentialConstants.PRESIGN; +import static io.testomat.core.constants.CredentialConstants.REGION; +import static io.testomat.core.constants.CredentialConstants.SECRET_ACCESS_KEY; +import static io.testomat.core.constants.CredentialConstants.SHARED; + +import io.testomat.core.exception.ArtifactManagementException; +import io.testomat.core.propertyconfig.impl.PropertyProviderFactoryImpl; +import io.testomat.core.propertyconfig.interf.PropertyProvider; +import java.lang.reflect.Field; import java.util.Map; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -16,39 +24,61 @@ public class CredentialsManager { private static final Logger log = LoggerFactory.getLogger(CredentialsManager.class); private static final S3Credentials credentials = new S3Credentials(); + private static final String SECRET_ACCESS_KEY_FIELD = "secretAccessKey"; + private static final String ACCESS_KEY_ID_FIELD = "accessKeyId"; + private static final String BUCKET_FIELD = "bucket"; + private static final String REGION_FIELD = "region"; + + private final PropertyProvider provider = + PropertyProviderFactoryImpl.getPropertyProviderFactory().getPropertyProvider(); + public static S3Credentials getCredentials() { return credentials; } - public void populateCredentialsFromServerResponse(Map credsFromServer) { - log.debug("Populating S3 credentials from configuration map"); + public void populateCredentials(Map credsFromServer) { + log.debug("Populating S3 credentials"); if (credsFromServer == null || credsFromServer.isEmpty()) { log.warn("Received null or empty credentials map"); return; } + credentials.setIam(getBoolean(credsFromServer, IAM, false)); - credentials.setPresign(getBoolean(credsFromServer, PRESIGN_PROPERTY_NAME, false)); - credentials.setShared(getBoolean(credsFromServer, SHARED_PROPERTY_NAME, false)); - credentials.setIam(getBoolean(credsFromServer, IAM_PROPERTY_NAME, false)); - credentials.setSecretAccessKey(getString(credsFromServer, SECRET_KEY_PROPERTY_NAME)); - credentials.setAccessKeyId(getString(credsFromServer, ACCESS_KEY_PROPERTY_NAME)); - credentials.setBucket(getString(credsFromServer, BUCKET_PROPERTY_NAME)); - credentials.setRegion(getString(credsFromServer, REGION_PROPERTY_NAME)); - log.info("S3 credentials populated: bucket={}, region={}, presign={}, shared={}, iam={}", - credentials.getBucket(), credentials.getRegion(), - credentials.isPresign(), credentials.isShared(), credentials.isIam()); + credentials.setPresign(getBoolean(credsFromServer, PRESIGN, false)); + credentials.setShared(getBoolean(credsFromServer, SHARED, false)); - if (!areCredentialsAvailable()) { - log.error("Credentials population completed but essential fields are missing"); + if (getPropertyFromEnv(SECRET_ACCESS_KEY_PROPERTY_NAME) != null) { + populateCredentialsFromEnv(getPropertyFromEnv(SECRET_ACCESS_KEY_PROPERTY_NAME), SECRET_ACCESS_KEY_FIELD); + log.debug("SecretAccessKey from env"); } else { - log.debug("All required S3 credentials are available"); + credentials.setSecretAccessKey(getString(credsFromServer, SECRET_ACCESS_KEY)); + log.debug("SecretAccessKey from server"); } - } - public void populateCredentialsFromEnvironment() { + if (getPropertyFromEnv(ACCESS_KEY_PROPERTY_NAME) != null) { + populateCredentialsFromEnv(getPropertyFromEnv(ACCESS_KEY_PROPERTY_NAME), ACCESS_KEY_ID_FIELD); + log.debug("AccessKey from env"); + + } else { + credentials.setAccessKeyId(getString(credsFromServer, ACCESS_KEY_ID)); + log.debug("AccessKey from server"); + } + + if (getPropertyFromEnv(BUCKET_PROPERTY_NAME) != null) { + populateCredentialsFromEnv(getPropertyFromEnv(BUCKET_PROPERTY_NAME), BUCKET_FIELD); + } else { + credentials.setBucket(getString(credsFromServer, BUCKET)); + } + + if (getPropertyFromEnv(REGION_PROPERTY_NAME) != null) { + populateCredentialsFromEnv(getPropertyFromEnv(REGION_PROPERTY_NAME), REGION_FIELD); + } else { + credentials.setRegion(getString(credsFromServer, REGION)); + } + logCredentialsInitializationResult(); } private boolean areCredentialsAvailable() { @@ -57,7 +87,10 @@ private boolean areCredentialsAvailable() { boolean bucketAvailable = credentials.getBucket() != null; boolean regionAvailable = credentials.getRegion() != null; - boolean allAvailable = accessKeyAvailable && secretKeyAvailable && bucketAvailable && regionAvailable; + boolean allAvailable = accessKeyAvailable + && secretKeyAvailable + && bucketAvailable + && regionAvailable; if (!allAvailable) { log.warn("Missing S3 credentials - accessKey: {}, secretKey: {}, bucket: {}, region: {}", @@ -76,4 +109,40 @@ private String getString(Map map, String key) { Object value = map.get(key); return value != null ? value.toString() : null; } + + private Object getPropertyFromEnv(String propertyName) { + try { + return provider.getProperty(propertyName); + } catch (Exception e) { + return null; + } + } + + private void populateCredentialsFromEnv(Object envPropertyValue, String fieldName) { + Field field; + try { + field = credentials.getClass().getDeclaredField(fieldName); + field.setAccessible(true); + field.set(credentials, envPropertyValue); + } catch (NoSuchFieldException e) { + throw new ArtifactManagementException("Failed to set credentials with reflection. " + + "The field does not exist: " + fieldName); + } catch (IllegalAccessException e) { + throw new ArtifactManagementException("Inaccessible field: " + fieldName); + } + + } + + + private void logCredentialsInitializationResult() { + log.info("S3 credentials populated: bucket={}, region={}, presign={}, shared={}, iam={}", + credentials.getBucket(), credentials.getRegion(), + credentials.isPresign(), credentials.isShared(), credentials.isIam()); + + if (!areCredentialsAvailable()) { + log.error("Credentials population completed but essential fields are missing"); + } else { + log.debug("All required S3 credentials are available"); + } + } } diff --git a/java-reporter-core/src/main/java/io/testomat/core/client/TestomatioClient.java b/java-reporter-core/src/main/java/io/testomat/core/client/TestomatioClient.java index 0d5a687..bd2af7e 100644 --- a/java-reporter-core/src/main/java/io/testomat/core/client/TestomatioClient.java +++ b/java-reporter-core/src/main/java/io/testomat/core/client/TestomatioClient.java @@ -72,7 +72,7 @@ public String createRun(String title) throws IOException { } if (responseBody.containsKey("artifacts")) { Map creds = (Map) responseBody.get("artifacts"); - credentialsManager.populateCredentialsFromServerResponse(creds); + credentialsManager.populateCredentials(creds); } logAndPrintUrls(responseBody); log.debug("Created test run with UID: {}", responseBody.get(RESPONSE_UID_KEY)); diff --git a/java-reporter-core/src/main/java/io/testomat/core/constants/CredentialConstants.java b/java-reporter-core/src/main/java/io/testomat/core/constants/CredentialConstants.java index 662323b..3417b0d 100644 --- a/java-reporter-core/src/main/java/io/testomat/core/constants/CredentialConstants.java +++ b/java-reporter-core/src/main/java/io/testomat/core/constants/CredentialConstants.java @@ -1,13 +1,13 @@ package io.testomat.core.constants; public class CredentialConstants { - public static final String PRESIGN_PROPERTY_NAME = "presign"; - public static final String SHARED_PROPERTY_NAME = "shared"; - public static final String IAM_PROPERTY_NAME = "iam"; - public static final String SECRET_KEY_PROPERTY_NAME = "SECRET_ACCESS_KEY"; - public static final String ACCESS_KEY_PROPERTY_NAME = "ACCESS_KEY_ID"; - public static final String BUCKET_PROPERTY_NAME = "BUCKET"; - public static final String REGION_PROPERTY_NAME = "REGION"; + public static final String PRESIGN = "presign"; + public static final String SHARED = "shared"; + public static final String IAM = "iam"; + public static final String SECRET_ACCESS_KEY = "SECRET_ACCESS_KEY"; + public static final String ACCESS_KEY_ID = "ACCESS_KEY_ID"; + public static final String BUCKET = "BUCKET"; + public static final String REGION = "REGION"; private CredentialConstants() { } From d76bae1f2000d11dda12ebd78884703ad1e09c84 Mon Sep 17 00:00:00 2001 From: Yevhenii Vlasenko Date: Thu, 18 Sep 2025 22:42:12 +0300 Subject: [PATCH 16/44] implemented custom endpoint and forcepath. refactoring required. --- .../core/artifact/client/AwsClient.java | 50 +----- .../core/artifact/client/AwsService.java | 18 -- .../core/artifact/client/S3ClientFactory.java | 92 +++++++++++ .../artifact/credential/S3Credentials.java | 155 +++++++++--------- 4 files changed, 182 insertions(+), 133 deletions(-) create mode 100644 java-reporter-core/src/main/java/io/testomat/core/artifact/client/S3ClientFactory.java diff --git a/java-reporter-core/src/main/java/io/testomat/core/artifact/client/AwsClient.java b/java-reporter-core/src/main/java/io/testomat/core/artifact/client/AwsClient.java index b694007..d2aac63 100644 --- a/java-reporter-core/src/main/java/io/testomat/core/artifact/client/AwsClient.java +++ b/java-reporter-core/src/main/java/io/testomat/core/artifact/client/AwsClient.java @@ -1,65 +1,31 @@ package io.testomat.core.artifact.client; -import io.testomat.core.artifact.credential.CredentialsManager; import io.testomat.core.artifact.credential.CredentialsValidationService; -import io.testomat.core.artifact.credential.S3Credentials; -import io.testomat.core.exception.ArtifactManagementException; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import software.amazon.awssdk.auth.credentials.AwsBasicCredentials; -import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider; -import software.amazon.awssdk.regions.Region; import software.amazon.awssdk.services.s3.S3Client; public class AwsClient { private static final Logger log = LoggerFactory.getLogger(AwsClient.class); private final CredentialsValidationService validationService; private volatile S3Client s3Client; + private S3ClientFactory clientFactory; public AwsClient() { this.validationService = new CredentialsValidationService(); + this.clientFactory = new S3ClientFactory(); } - public AwsClient(CredentialsValidationService validationService) { + public AwsClient(CredentialsValidationService validationService, S3ClientFactory s3ClientFactory) { this.validationService = validationService; + this.clientFactory = s3ClientFactory; } public S3Client getS3Client() { - S3Client client = s3Client; - if (client == null) { - synchronized (this) { - client = s3Client; - if (client == null) { - log.debug("Lazy initializing S3 Client"); - s3Client = client = initialize(); - } - } + if (s3Client == null) { + s3Client = clientFactory.createS3Client(); + return s3Client; } - return client; - } - - private S3Client initialize() { - log.debug("Initializing S3 Client"); - S3Credentials s3Credentials = CredentialsManager.getCredentials(); - - if (!validationService.areCredentialsValid(s3Credentials)) { - log.error("S3 credentials validation failed during client initialization. The artifacts won't be handled"); - } - - log.debug("Creating S3 client for region: {}, bucket: {}", - s3Credentials.getRegion(), s3Credentials.getBucket()); - - AwsBasicCredentials basicCredentials = AwsBasicCredentials.create( - s3Credentials.getAccessKeyId(), - s3Credentials.getSecretAccessKey()); - - StaticCredentialsProvider staticCredentialsProvider = StaticCredentialsProvider.create(basicCredentials); - S3Client client = S3Client.builder() - .credentialsProvider(staticCredentialsProvider) - .region(Region.of(s3Credentials.getRegion())) - .build(); - - log.info("S3 Client initialized successfully for region: {}", s3Credentials.getRegion()); - return client; + return s3Client; } } diff --git a/java-reporter-core/src/main/java/io/testomat/core/artifact/client/AwsService.java b/java-reporter-core/src/main/java/io/testomat/core/artifact/client/AwsService.java index 1a37bdc..f24880a 100644 --- a/java-reporter-core/src/main/java/io/testomat/core/artifact/client/AwsService.java +++ b/java-reporter-core/src/main/java/io/testomat/core/artifact/client/AwsService.java @@ -41,24 +41,6 @@ public AwsService(ArtifactKeyGenerator keyGenerator, AwsClient awsClient, log.debug("AWS Service initialized"); } -// public void uploadAllArtifactsForTest(String testName, String rid) { -// if (TempArtifactDirectoriesStorage.DIRECTORIES.get().isEmpty()) { -// log.debug("Artifact list is empty for test: {}", testName); -// return; -// } -// -// S3Credentials credentials = CredentialsManager.getCredentials(); -// List uploadedArtifactsLinks = new ArrayList<>(); -// -// for (String dir : TempArtifactDirectoriesStorage.DIRECTORIES.get()) { -// String key = keyGenerator.generateKey(dir, rid, testName); -// uploadArtifact(dir, key, credentials); -// uploadedArtifactsLinks.add(urlGenerator.generateUrl(credentials.getBucket(), key)); -// } -// -// UploadedArtifactLinksStorage.store(rid, uploadedArtifactsLinks); -// } - public void uploadAllArtifactsForTest(String testName, String rid, String testId) { if (TempArtifactDirectoriesStorage.DIRECTORIES.get().isEmpty()) { log.debug("Artifact list is empty for test: {}", testName); diff --git a/java-reporter-core/src/main/java/io/testomat/core/artifact/client/S3ClientFactory.java b/java-reporter-core/src/main/java/io/testomat/core/artifact/client/S3ClientFactory.java new file mode 100644 index 0000000..a61bdec --- /dev/null +++ b/java-reporter-core/src/main/java/io/testomat/core/artifact/client/S3ClientFactory.java @@ -0,0 +1,92 @@ +package io.testomat.core.artifact.client; + +import io.testomat.core.artifact.credential.CredentialsManager; +import io.testomat.core.artifact.credential.S3Credentials; +import software.amazon.awssdk.auth.credentials.AwsBasicCredentials; +import software.amazon.awssdk.auth.credentials.AwsCredentialsProvider; +import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider; +import software.amazon.awssdk.auth.credentials.DefaultCredentialsProvider; +import software.amazon.awssdk.regions.Region; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.S3ClientBuilder; +import software.amazon.awssdk.services.s3.S3Configuration; +import java.net.URI; + +/** + * Factory для створення S3Client з підтримкою кастомних ендпоінтів + */ +public class S3ClientFactory { + private final boolean forcePath; + private final String customEndpoint; + private final String accessKey; + private final String secretAccessKey; + private final String region; + + public S3ClientFactory() { + S3Credentials s3Credentials = CredentialsManager.getCredentials(); + this.forcePath = s3Credentials.isForcePath(); + this.customEndpoint = s3Credentials.getCustomEndpoint(); + this.accessKey = s3Credentials.getAccessKeyId(); + this.secretAccessKey = s3Credentials.getSecretAccessKey(); + this.region = s3Credentials.getRegion(); + } + + public S3Client createS3Client() { + S3Credentials s3Credentials = CredentialsManager.getCredentials(); + + S3ClientBuilder builder = S3Client.builder(); + + // Налаштування кредів - завжди використовуємо креди з полів класу + AwsCredentialsProvider credentialsProvider; + if (s3Credentials.getAccessKeyId() != null && s3Credentials.getSecretAccessKey() != null) { + // Валідація кредів + if (s3Credentials.getAccessKeyId().trim().isEmpty() || s3Credentials.getSecretAccessKey().trim().isEmpty()) { + throw new IllegalArgumentException("Access key and secret access key cannot be empty"); + } + // Використовуємо передані креди + AwsBasicCredentials credentials = AwsBasicCredentials.create(s3Credentials.getAccessKeyId().trim(), s3Credentials.getSecretAccessKey().trim()); + credentialsProvider = StaticCredentialsProvider.create(credentials); + } else { + // Якщо креди не налаштовані через CredentialsManager, кидаємо помилку + throw new IllegalArgumentException("S3 credentials (access key and secret access key) must be configured"); + } + builder.credentialsProvider(credentialsProvider); + + // Налаштування регіону + if (s3Credentials.getRegion() != null && !s3Credentials.getRegion().trim().isEmpty()) { + try { + builder.region(Region.of(s3Credentials.getRegion().trim())); + } catch (Exception e) { + throw new IllegalArgumentException("Invalid region: " + s3Credentials.getRegion(), e); + } + } else { + // Дефолтний регіон для кастомних ендпоінтів або AWS + builder.region(Region.US_EAST_1); + } + + // Якщо є кастомний ендпоінт - налаштовуємо його + if (s3Credentials.getCustomEndpoint() != null && !s3Credentials.getCustomEndpoint().trim().isEmpty()) { + try { + builder.endpointOverride(URI.create(s3Credentials.getCustomEndpoint().trim())); + } catch (Exception e) { + throw new IllegalArgumentException("Invalid endpoint URL: " + s3Credentials.getCustomEndpoint(), e); + } + + // Для кастомних ендпоінтів часто потрібен path-style addressing + S3Configuration s3Config = S3Configuration.builder() + .pathStyleAccessEnabled(s3Credentials.isForcePath()) + .build(); + builder.serviceConfiguration(s3Config); + } else { + // Для дефолтного AWS S3 forcePath зазвичай false + if (s3Credentials.isForcePath()) { + S3Configuration s3Config = S3Configuration.builder() + .pathStyleAccessEnabled(true) + .build(); + builder.serviceConfiguration(s3Config); + } + } + + return builder.build(); + } +} \ No newline at end of file diff --git a/java-reporter-core/src/main/java/io/testomat/core/artifact/credential/S3Credentials.java b/java-reporter-core/src/main/java/io/testomat/core/artifact/credential/S3Credentials.java index eddc940..461123d 100644 --- a/java-reporter-core/src/main/java/io/testomat/core/artifact/credential/S3Credentials.java +++ b/java-reporter-core/src/main/java/io/testomat/core/artifact/credential/S3Credentials.java @@ -1,76 +1,85 @@ package io.testomat.core.artifact.credential; public class S3Credentials { - private boolean presign; - private boolean shared; - private boolean iam; - private String secretAccessKey; - private String accessKeyId; - private String bucket; - private String region; - private String customEndpoint; - - public boolean isPresign() { - return presign; - } - - public void setPresign(boolean presign) { - this.presign = presign; - } - - public boolean isShared() { - return shared; - } - - public void setShared(boolean shared) { - this.shared = shared; - } - - public boolean isIam() { - return iam; - } - - public void setIam(boolean iam) { - this.iam = iam; - } - - public String getSecretAccessKey() { - return secretAccessKey; - } - - public void setSecretAccessKey(String secretAccessKey) { - this.secretAccessKey = secretAccessKey; - } - - public String getAccessKeyId() { - return accessKeyId; - } - - public void setAccessKeyId(String accessKeyId) { - this.accessKeyId = accessKeyId; - } - - public String getBucket() { - return bucket; - } - - public void setBucket(String bucket) { - this.bucket = bucket; - } - - public String getRegion() { - return region; - } - - public void setRegion(String region) { - this.region = region; - } - - public String getCustomEndpoint() { - return customEndpoint; - } - - public void setCustomEndpoint(String customEndpoint) { - this.customEndpoint = customEndpoint; - } - } \ No newline at end of file + private boolean presign; + private boolean shared; + private boolean iam; + private String secretAccessKey; + private String accessKeyId; + private String bucket; + private String region; + private String customEndpoint; + private boolean forcePath = false; + + public boolean isForcePath() { + return forcePath; + } + + public void setForcePath(boolean forcePath) { + this.forcePath = forcePath; + } + + public boolean isPresign() { + return presign; + } + + public void setPresign(boolean presign) { + this.presign = presign; + } + + public boolean isShared() { + return shared; + } + + public void setShared(boolean shared) { + this.shared = shared; + } + + public boolean isIam() { + return iam; + } + + public void setIam(boolean iam) { + this.iam = iam; + } + + public String getSecretAccessKey() { + return secretAccessKey; + } + + public void setSecretAccessKey(String secretAccessKey) { + this.secretAccessKey = secretAccessKey; + } + + public String getAccessKeyId() { + return accessKeyId; + } + + public void setAccessKeyId(String accessKeyId) { + this.accessKeyId = accessKeyId; + } + + public String getBucket() { + return bucket; + } + + public void setBucket(String bucket) { + this.bucket = bucket; + } + + public String getRegion() { + return region; + } + + public void setRegion(String region) { + this.region = region; + } + + public String getCustomEndpoint() { + return customEndpoint; + } + + public void setCustomEndpoint(String customEndpoint) { + this.customEndpoint = customEndpoint; + } +} \ No newline at end of file From 7370edc4c4548fd0e60f3e2ee712c6d1243b2111 Mon Sep 17 00:00:00 2001 From: Yevhenii Vlasenko Date: Fri, 19 Sep 2025 13:44:57 +0300 Subject: [PATCH 17/44] implemented custom endpoint and forcepath. refactoring required. --- .../core/artifact/client/AwsService.java | 16 ++++--- .../core/artifact/client/S3ClientFactory.java | 40 +++++++---------- .../credential/CredentialsManager.java | 43 ++++++++++++++++--- .../CredentialsValidationService.java | 12 +++--- .../core/constants/ArtifactPropertyNames.java | 1 - .../core/constants/CredentialConstants.java | 2 + 6 files changed, 70 insertions(+), 44 deletions(-) diff --git a/java-reporter-core/src/main/java/io/testomat/core/artifact/client/AwsService.java b/java-reporter-core/src/main/java/io/testomat/core/artifact/client/AwsService.java index f24880a..7a94f50 100644 --- a/java-reporter-core/src/main/java/io/testomat/core/artifact/client/AwsService.java +++ b/java-reporter-core/src/main/java/io/testomat/core/artifact/client/AwsService.java @@ -3,7 +3,6 @@ import io.testomat.core.ArtifactLinkData; import io.testomat.core.ArtifactLinkDataStorage; import io.testomat.core.artifact.TempArtifactDirectoriesStorage; -import io.testomat.core.artifact.UploadedArtifactLinksStorage; import io.testomat.core.artifact.credential.CredentialsManager; import io.testomat.core.artifact.credential.S3Credentials; import io.testomat.core.artifact.util.ArtifactKeyGenerator; @@ -38,7 +37,6 @@ public AwsService(ArtifactKeyGenerator keyGenerator, AwsClient awsClient, this.keyGenerator = keyGenerator; this.awsClient = awsClient; this.urlGenerator = urlGenerator; - log.debug("AWS Service initialized"); } public void uploadAllArtifactsForTest(String testName, String rid, String testId) { @@ -57,7 +55,7 @@ public void uploadAllArtifactsForTest(String testName, String rid, String testId } // UploadedArtifactLinksStorage.store(rid, uploadedArtifactsLinks); - ArtifactLinkDataStorage.ARTEFACT_LINK_DATA_STORAGE.add(new ArtifactLinkData(testName, rid,testId, uploadedArtifactsLinks)); + ArtifactLinkDataStorage.ARTEFACT_LINK_DATA_STORAGE.add(new ArtifactLinkData(testName, rid, testId, uploadedArtifactsLinks)); } private void uploadArtifact(String dir, String key, S3Credentials credentials) { @@ -71,10 +69,16 @@ private void uploadArtifact(String dir, String key, S3Credentials credentials) { throw new ArtifactManagementException("Failed to read bytes from path: " + path); } - PutObjectRequest request = PutObjectRequest.builder() + PutObjectRequest.Builder builder = PutObjectRequest.builder() .bucket(credentials.getBucket()) - .key(key) - .build(); + .key(key); + + if (credentials.isPresign()) { + builder.acl("private"); + } else { + builder.acl("public-read"); + } + PutObjectRequest request = builder.build(); log.debug("Uploading to S3: bucket={}, key={}, size={} bytes", credentials.getBucket(), key, content.length); diff --git a/java-reporter-core/src/main/java/io/testomat/core/artifact/client/S3ClientFactory.java b/java-reporter-core/src/main/java/io/testomat/core/artifact/client/S3ClientFactory.java index a61bdec..a665c9f 100644 --- a/java-reporter-core/src/main/java/io/testomat/core/artifact/client/S3ClientFactory.java +++ b/java-reporter-core/src/main/java/io/testomat/core/artifact/client/S3ClientFactory.java @@ -2,57 +2,51 @@ import io.testomat.core.artifact.credential.CredentialsManager; import io.testomat.core.artifact.credential.S3Credentials; +import java.net.URI; import software.amazon.awssdk.auth.credentials.AwsBasicCredentials; import software.amazon.awssdk.auth.credentials.AwsCredentialsProvider; import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider; -import software.amazon.awssdk.auth.credentials.DefaultCredentialsProvider; import software.amazon.awssdk.regions.Region; import software.amazon.awssdk.services.s3.S3Client; import software.amazon.awssdk.services.s3.S3ClientBuilder; import software.amazon.awssdk.services.s3.S3Configuration; -import java.net.URI; /** * Factory для створення S3Client з підтримкою кастомних ендпоінтів */ public class S3ClientFactory { - private final boolean forcePath; - private final String customEndpoint; - private final String accessKey; - private final String secretAccessKey; - private final String region; - - public S3ClientFactory() { - S3Credentials s3Credentials = CredentialsManager.getCredentials(); - this.forcePath = s3Credentials.isForcePath(); - this.customEndpoint = s3Credentials.getCustomEndpoint(); - this.accessKey = s3Credentials.getAccessKeyId(); - this.secretAccessKey = s3Credentials.getSecretAccessKey(); - this.region = s3Credentials.getRegion(); - } +// private final boolean forcePath; +// private final String customEndpoint; +// private final String accessKey; +// private final String secretAccessKey; +// private final String region; +// +// public S3ClientFactory() { +// S3Credentials s3Credentials = CredentialsManager.getCredentials(); +// this.forcePath = s3Credentials.isForcePath(); +// this.customEndpoint = s3Credentials.getCustomEndpoint(); +// this.accessKey = s3Credentials.getAccessKeyId(); +// this.secretAccessKey = s3Credentials.getSecretAccessKey(); +// this.region = s3Credentials.getRegion(); +// } public S3Client createS3Client() { S3Credentials s3Credentials = CredentialsManager.getCredentials(); S3ClientBuilder builder = S3Client.builder(); - // Налаштування кредів - завжди використовуємо креди з полів класу AwsCredentialsProvider credentialsProvider; if (s3Credentials.getAccessKeyId() != null && s3Credentials.getSecretAccessKey() != null) { - // Валідація кредів if (s3Credentials.getAccessKeyId().trim().isEmpty() || s3Credentials.getSecretAccessKey().trim().isEmpty()) { throw new IllegalArgumentException("Access key and secret access key cannot be empty"); } - // Використовуємо передані креди AwsBasicCredentials credentials = AwsBasicCredentials.create(s3Credentials.getAccessKeyId().trim(), s3Credentials.getSecretAccessKey().trim()); credentialsProvider = StaticCredentialsProvider.create(credentials); } else { - // Якщо креди не налаштовані через CredentialsManager, кидаємо помилку throw new IllegalArgumentException("S3 credentials (access key and secret access key) must be configured"); } builder.credentialsProvider(credentialsProvider); - // Налаштування регіону if (s3Credentials.getRegion() != null && !s3Credentials.getRegion().trim().isEmpty()) { try { builder.region(Region.of(s3Credentials.getRegion().trim())); @@ -60,11 +54,9 @@ public S3Client createS3Client() { throw new IllegalArgumentException("Invalid region: " + s3Credentials.getRegion(), e); } } else { - // Дефолтний регіон для кастомних ендпоінтів або AWS builder.region(Region.US_EAST_1); } - // Якщо є кастомний ендпоінт - налаштовуємо його if (s3Credentials.getCustomEndpoint() != null && !s3Credentials.getCustomEndpoint().trim().isEmpty()) { try { builder.endpointOverride(URI.create(s3Credentials.getCustomEndpoint().trim())); @@ -72,13 +64,11 @@ public S3Client createS3Client() { throw new IllegalArgumentException("Invalid endpoint URL: " + s3Credentials.getCustomEndpoint(), e); } - // Для кастомних ендпоінтів часто потрібен path-style addressing S3Configuration s3Config = S3Configuration.builder() .pathStyleAccessEnabled(s3Credentials.isForcePath()) .build(); builder.serviceConfiguration(s3Config); } else { - // Для дефолтного AWS S3 forcePath зазвичай false if (s3Credentials.isForcePath()) { S3Configuration s3Config = S3Configuration.builder() .pathStyleAccessEnabled(true) diff --git a/java-reporter-core/src/main/java/io/testomat/core/artifact/credential/CredentialsManager.java b/java-reporter-core/src/main/java/io/testomat/core/artifact/credential/CredentialsManager.java index 481ca4c..4de5b7d 100644 --- a/java-reporter-core/src/main/java/io/testomat/core/artifact/credential/CredentialsManager.java +++ b/java-reporter-core/src/main/java/io/testomat/core/artifact/credential/CredentialsManager.java @@ -2,10 +2,15 @@ import static io.testomat.core.constants.ArtifactPropertyNames.ACCESS_KEY_PROPERTY_NAME; import static io.testomat.core.constants.ArtifactPropertyNames.BUCKET_PROPERTY_NAME; +import static io.testomat.core.constants.ArtifactPropertyNames.ENDPOINT_PROPERTY_NAME; +import static io.testomat.core.constants.ArtifactPropertyNames.FORCE_PATH_PROPERTY_NAME; +import static io.testomat.core.constants.ArtifactPropertyNames.PRIVATE_ARTIFACTS_PROPERTY_NAME; import static io.testomat.core.constants.ArtifactPropertyNames.REGION_PROPERTY_NAME; import static io.testomat.core.constants.ArtifactPropertyNames.SECRET_ACCESS_KEY_PROPERTY_NAME; import static io.testomat.core.constants.CredentialConstants.ACCESS_KEY_ID; import static io.testomat.core.constants.CredentialConstants.BUCKET; +import static io.testomat.core.constants.CredentialConstants.ENDPOINT; +import static io.testomat.core.constants.CredentialConstants.FORCE_PATH; import static io.testomat.core.constants.CredentialConstants.IAM; import static io.testomat.core.constants.CredentialConstants.PRESIGN; import static io.testomat.core.constants.CredentialConstants.REGION; @@ -28,6 +33,9 @@ public class CredentialsManager { private static final String ACCESS_KEY_ID_FIELD = "accessKeyId"; private static final String BUCKET_FIELD = "bucket"; private static final String REGION_FIELD = "region"; + private static final String PRESIGN_FIELD = "presign"; + private static final String ENDPOINT_FIELD_NAME = "customEndpoint"; + private static final String FORCE_PATH_STYLE_FIELD = "forcePath"; private final PropertyProvider provider = PropertyProviderFactoryImpl.getPropertyProviderFactory().getPropertyProvider(); @@ -43,11 +51,30 @@ public void populateCredentials(Map credsFromServer) { log.warn("Received null or empty credentials map"); return; } - credentials.setIam(getBoolean(credsFromServer, IAM, false)); + if (getPropertyFromEnv(FORCE_PATH_PROPERTY_NAME) != null) { + populateCredentialsFromEnv(getPropertyFromEnv(FORCE_PATH_PROPERTY_NAME), FORCE_PATH_STYLE_FIELD); + log.debug("ForcePath from env"); + } else { + credentials.setPresign(getBoolean(credsFromServer, FORCE_PATH, false)); + log.debug("ForcePath from server"); + } - credentials.setPresign(getBoolean(credsFromServer, PRESIGN, false)); - credentials.setShared(getBoolean(credsFromServer, SHARED, false)); + if (getPropertyFromEnv(ENDPOINT_PROPERTY_NAME) != null) { + populateCredentialsFromEnv(getPropertyFromEnv(ENDPOINT_PROPERTY_NAME), ENDPOINT_FIELD_NAME); + log.debug("Endpoint from env"); + } else { + credentials.setCustomEndpoint(getString(credsFromServer, ENDPOINT)); + log.debug("Endpoint from server"); + } + + if (getPropertyFromEnv(PRIVATE_ARTIFACTS_PROPERTY_NAME) != null) { + populateCredentialsFromEnv(getPropertyFromEnv(PRIVATE_ARTIFACTS_PROPERTY_NAME), PRESIGN_FIELD); + log.debug("Presign from env"); + } else { + credentials.setPresign(getBoolean(credsFromServer, PRESIGN, false)); + log.debug("Presign from server"); + } if (getPropertyFromEnv(SECRET_ACCESS_KEY_PROPERTY_NAME) != null) { populateCredentialsFromEnv(getPropertyFromEnv(SECRET_ACCESS_KEY_PROPERTY_NAME), SECRET_ACCESS_KEY_FIELD); @@ -60,7 +87,6 @@ public void populateCredentials(Map credsFromServer) { if (getPropertyFromEnv(ACCESS_KEY_PROPERTY_NAME) != null) { populateCredentialsFromEnv(getPropertyFromEnv(ACCESS_KEY_PROPERTY_NAME), ACCESS_KEY_ID_FIELD); log.debug("AccessKey from env"); - } else { credentials.setAccessKeyId(getString(credsFromServer, ACCESS_KEY_ID)); log.debug("AccessKey from server"); @@ -68,16 +94,23 @@ public void populateCredentials(Map credsFromServer) { if (getPropertyFromEnv(BUCKET_PROPERTY_NAME) != null) { populateCredentialsFromEnv(getPropertyFromEnv(BUCKET_PROPERTY_NAME), BUCKET_FIELD); + log.debug("Bucket from env"); } else { credentials.setBucket(getString(credsFromServer, BUCKET)); + log.debug("Bucket from server"); } if (getPropertyFromEnv(REGION_PROPERTY_NAME) != null) { populateCredentialsFromEnv(getPropertyFromEnv(REGION_PROPERTY_NAME), REGION_FIELD); + log.debug("Region from env"); } else { credentials.setRegion(getString(credsFromServer, REGION)); + log.debug("Region from server"); } + credentials.setIam(getBoolean(credsFromServer, IAM, false)); + credentials.setShared(getBoolean(credsFromServer, SHARED, false)); + logCredentialsInitializationResult(); } @@ -130,10 +163,8 @@ private void populateCredentialsFromEnv(Object envPropertyValue, String fieldNam } catch (IllegalAccessException e) { throw new ArtifactManagementException("Inaccessible field: " + fieldName); } - } - private void logCredentialsInitializationResult() { log.info("S3 credentials populated: bucket={}, region={}, presign={}, shared={}, iam={}", credentials.getBucket(), credentials.getRegion(), diff --git a/java-reporter-core/src/main/java/io/testomat/core/artifact/credential/CredentialsValidationService.java b/java-reporter-core/src/main/java/io/testomat/core/artifact/credential/CredentialsValidationService.java index c1388f9..9cf3d16 100644 --- a/java-reporter-core/src/main/java/io/testomat/core/artifact/credential/CredentialsValidationService.java +++ b/java-reporter-core/src/main/java/io/testomat/core/artifact/credential/CredentialsValidationService.java @@ -19,16 +19,16 @@ public boolean areCredentialsValid(S3Credentials creds) { } if (creds.getAccessKeyId() == null || creds.getSecretAccessKey() == null || - creds.getRegion() == null || creds.getBucket() == null) { + creds.getRegion() == null || creds.getBucket() == null) { log.error("S3 credentials validation failed: missing required fields - " + - "accessKey: {}, secretKey: {}, region: {}, bucket: {}", - creds.getAccessKeyId() != null, creds.getSecretAccessKey() != null, - creds.getRegion() != null, creds.getBucket() != null); + "accessKey: {}, secretKey: {}, region: {}, bucket: {}", + creds.getAccessKeyId() != null, creds.getSecretAccessKey() != null, + creds.getRegion() != null, creds.getBucket() != null); return false; } log.debug("Validating S3 credentials for bucket: {} in region: {}", - creds.getBucket(), creds.getRegion()); + creds.getBucket(), creds.getRegion()); try (S3Client client = S3Client.builder() .credentialsProvider(StaticCredentialsProvider.create( @@ -50,7 +50,7 @@ public boolean areCredentialsValid(S3Credentials creds) { return false; } catch (Exception e) { log.error("S3 connection error during validation for bucket: {} - {}", - creds.getBucket(), e.getMessage(), e); + creds.getBucket(), e.getMessage(), e); return false; } } diff --git a/java-reporter-core/src/main/java/io/testomat/core/constants/ArtifactPropertyNames.java b/java-reporter-core/src/main/java/io/testomat/core/constants/ArtifactPropertyNames.java index 77f4aa9..6421864 100644 --- a/java-reporter-core/src/main/java/io/testomat/core/constants/ArtifactPropertyNames.java +++ b/java-reporter-core/src/main/java/io/testomat/core/constants/ArtifactPropertyNames.java @@ -7,7 +7,6 @@ public class ArtifactPropertyNames { public static final String SECRET_ACCESS_KEY_PROPERTY_NAME = " s3.secret.access.key.id"; public static final String REGION_PROPERTY_NAME = "s3.region"; public static final String ENDPOINT_PROPERTY_NAME = "s3.endpoint"; - public static final String SESSION_TOKEN_PROPERTY_NAME = "s3.session.token"; public static final String FORCE_PATH_PROPERTY_NAME = "s3.force.path.style"; public static final String PRIVATE_ARTIFACTS_PROPERTY_NAME = "testomatio.private.artifacts"; diff --git a/java-reporter-core/src/main/java/io/testomat/core/constants/CredentialConstants.java b/java-reporter-core/src/main/java/io/testomat/core/constants/CredentialConstants.java index 3417b0d..e90dbca 100644 --- a/java-reporter-core/src/main/java/io/testomat/core/constants/CredentialConstants.java +++ b/java-reporter-core/src/main/java/io/testomat/core/constants/CredentialConstants.java @@ -8,6 +8,8 @@ public class CredentialConstants { public static final String ACCESS_KEY_ID = "ACCESS_KEY_ID"; public static final String BUCKET = "BUCKET"; public static final String REGION = "REGION"; + public static final String ENDPOINT = "ENDPOINT"; + public static final String FORCE_PATH = "FORCE_PATH_STYLE"; private CredentialConstants() { } From 2904eb740b04faa8c4535c9f404842d345e909cd Mon Sep 17 00:00:00 2001 From: Yevhenii Vlasenko Date: Mon, 22 Sep 2025 19:59:21 +0300 Subject: [PATCH 18/44] minor refactoring --- .../io/testomat/core/ArtifactLinkData.java | 18 +++--------------- .../core/artifact/client/AwsClient.java | 15 ++++++++++----- .../core/artifact/client/AwsService.java | 1 - .../core/artifact/client/S3ClientFactory.java | 14 -------------- .../testomat/core/client/TestomatioClient.java | 3 +++ 5 files changed, 16 insertions(+), 35 deletions(-) diff --git a/java-reporter-core/src/main/java/io/testomat/core/ArtifactLinkData.java b/java-reporter-core/src/main/java/io/testomat/core/ArtifactLinkData.java index 661053f..3f7575e 100644 --- a/java-reporter-core/src/main/java/io/testomat/core/ArtifactLinkData.java +++ b/java-reporter-core/src/main/java/io/testomat/core/ArtifactLinkData.java @@ -4,10 +4,10 @@ public class ArtifactLinkData { private String rid; - private String testId; - private String testName; + private final String testId; + private final String testName; - private List links; + private final List links; public ArtifactLinkData(String testName, String rid, String testId, List links) { this.testName = testName; @@ -20,18 +20,10 @@ public List getLinks() { return links; } - public void setLinks(List links) { - this.links = links; - } - public String getTestId() { return testId; } - public void setTestId(String testId) { - this.testId = testId; - } - public String getRid() { return rid; } @@ -43,8 +35,4 @@ public void setRid(String rid) { public String getTestName() { return testName; } - - public void setTestName(String testName) { - this.testName = testName; - } } diff --git a/java-reporter-core/src/main/java/io/testomat/core/artifact/client/AwsClient.java b/java-reporter-core/src/main/java/io/testomat/core/artifact/client/AwsClient.java index d2aac63..dc352d9 100644 --- a/java-reporter-core/src/main/java/io/testomat/core/artifact/client/AwsClient.java +++ b/java-reporter-core/src/main/java/io/testomat/core/artifact/client/AwsClient.java @@ -7,20 +7,25 @@ public class AwsClient { private static final Logger log = LoggerFactory.getLogger(AwsClient.class); - private final CredentialsValidationService validationService; private volatile S3Client s3Client; - private S3ClientFactory clientFactory; + private final S3ClientFactory clientFactory; public AwsClient() { - this.validationService = new CredentialsValidationService(); this.clientFactory = new S3ClientFactory(); } - public AwsClient(CredentialsValidationService validationService, S3ClientFactory s3ClientFactory) { - this.validationService = validationService; + /** + * Test Constructor + */ + public AwsClient(S3ClientFactory s3ClientFactory) { this.clientFactory = s3ClientFactory; } + /** + * Single copy getter + * + * @return S3Client + */ public S3Client getS3Client() { if (s3Client == null) { s3Client = clientFactory.createS3Client(); diff --git a/java-reporter-core/src/main/java/io/testomat/core/artifact/client/AwsService.java b/java-reporter-core/src/main/java/io/testomat/core/artifact/client/AwsService.java index 7a94f50..62069ea 100644 --- a/java-reporter-core/src/main/java/io/testomat/core/artifact/client/AwsService.java +++ b/java-reporter-core/src/main/java/io/testomat/core/artifact/client/AwsService.java @@ -54,7 +54,6 @@ public void uploadAllArtifactsForTest(String testName, String rid, String testId uploadedArtifactsLinks.add(urlGenerator.generateUrl(credentials.getBucket(), key)); } - // UploadedArtifactLinksStorage.store(rid, uploadedArtifactsLinks); ArtifactLinkDataStorage.ARTEFACT_LINK_DATA_STORAGE.add(new ArtifactLinkData(testName, rid, testId, uploadedArtifactsLinks)); } diff --git a/java-reporter-core/src/main/java/io/testomat/core/artifact/client/S3ClientFactory.java b/java-reporter-core/src/main/java/io/testomat/core/artifact/client/S3ClientFactory.java index a665c9f..094c370 100644 --- a/java-reporter-core/src/main/java/io/testomat/core/artifact/client/S3ClientFactory.java +++ b/java-reporter-core/src/main/java/io/testomat/core/artifact/client/S3ClientFactory.java @@ -15,20 +15,6 @@ * Factory для створення S3Client з підтримкою кастомних ендпоінтів */ public class S3ClientFactory { -// private final boolean forcePath; -// private final String customEndpoint; -// private final String accessKey; -// private final String secretAccessKey; -// private final String region; -// -// public S3ClientFactory() { -// S3Credentials s3Credentials = CredentialsManager.getCredentials(); -// this.forcePath = s3Credentials.isForcePath(); -// this.customEndpoint = s3Credentials.getCustomEndpoint(); -// this.accessKey = s3Credentials.getAccessKeyId(); -// this.secretAccessKey = s3Credentials.getSecretAccessKey(); -// this.region = s3Credentials.getRegion(); -// } public S3Client createS3Client() { S3Credentials s3Credentials = CredentialsManager.getCredentials(); diff --git a/java-reporter-core/src/main/java/io/testomat/core/client/TestomatioClient.java b/java-reporter-core/src/main/java/io/testomat/core/client/TestomatioClient.java index bd2af7e..1a7e1ec 100644 --- a/java-reporter-core/src/main/java/io/testomat/core/client/TestomatioClient.java +++ b/java-reporter-core/src/main/java/io/testomat/core/client/TestomatioClient.java @@ -7,6 +7,7 @@ import io.testomat.core.ReportedTestStorage; import io.testomat.core.artifact.LinkUploadBodyBuilder; import io.testomat.core.artifact.credential.CredentialsManager; +import io.testomat.core.artifact.credential.CredentialsValidationService; import io.testomat.core.client.http.CustomHttpClient; import io.testomat.core.client.request.NativeRequestBodyBuilder; import io.testomat.core.client.request.RequestBodyBuilder; @@ -38,6 +39,7 @@ public class TestomatioClient implements ApiInterface { private final RequestBodyBuilder requestBodyBuilder; private final CredentialsManager credentialsManager = new CredentialsManager(); private final LinkUploadBodyBuilder linkUploadBodyBuilder = new LinkUploadBodyBuilder(); + private final CredentialsValidationService credentialsValidationService = new CredentialsValidationService(); /** * Creates API client with injectable dependencies. @@ -73,6 +75,7 @@ public String createRun(String title) throws IOException { if (responseBody.containsKey("artifacts")) { Map creds = (Map) responseBody.get("artifacts"); credentialsManager.populateCredentials(creds); + credentialsValidationService.areCredentialsValid(CredentialsManager.getCredentials()); } logAndPrintUrls(responseBody); log.debug("Created test run with UID: {}", responseBody.get(RESPONSE_UID_KEY)); From 6658a312485e01fba0a1af10af6c0789eaba8d8c Mon Sep 17 00:00:00 2001 From: Yevhenii Vlasenko Date: Mon, 22 Sep 2025 20:35:12 +0300 Subject: [PATCH 19/44] refactored artifact related code --- .../core/artifact/LinkUploadBodyBuilder.java | 24 ---- .../UploadedArtifactLinksStorage.java | 20 --- .../core/artifact/client/AwsClient.java | 1 - .../credential/CredentialsManager.java | 119 ++++++------------ .../CredentialsValidationService.java | 21 ++-- .../core/client/TestomatioClient.java | 9 +- 6 files changed, 52 insertions(+), 142 deletions(-) delete mode 100644 java-reporter-core/src/main/java/io/testomat/core/artifact/UploadedArtifactLinksStorage.java diff --git a/java-reporter-core/src/main/java/io/testomat/core/artifact/LinkUploadBodyBuilder.java b/java-reporter-core/src/main/java/io/testomat/core/artifact/LinkUploadBodyBuilder.java index a1a54d7..3675234 100644 --- a/java-reporter-core/src/main/java/io/testomat/core/artifact/LinkUploadBodyBuilder.java +++ b/java-reporter-core/src/main/java/io/testomat/core/artifact/LinkUploadBodyBuilder.java @@ -13,30 +13,6 @@ public class LinkUploadBodyBuilder { private static final Logger log = LoggerFactory.getLogger(LinkUploadBodyBuilder.class); - // public String buildLinkUploadRequestBody(Map> map, String apiKey) { - // ObjectMapper mapper = new ObjectMapper(); - // ObjectNode rootNode = mapper.createObjectNode(); - // ArrayNode testsArray = mapper.createArrayNode(); - // - // for (Map.Entry> entry : map.entrySet()) { - // ObjectNode testNode = mapper.createObjectNode(); - // testNode.put("rid", entry.getKey()); - // testNode.set("artifacts", mapper.valueToTree(entry.getValue())); - // testsArray.add(testNode); - // } - // - // rootNode.put("api_key", apiKey); - // rootNode.set("tests", testsArray); - // - // String json = null; - // try { - // json = mapper.writeValueAsString(rootNode); - // } catch (JsonProcessingException e) { - // log.warn("Failed to convert convert link storage to json body"); - // } - // return json; - // } - public String buildLinkUploadRequestBody(List storedLinkData, String apiKey) { ObjectMapper mapper = new ObjectMapper(); ObjectNode rootNode = mapper.createObjectNode(); diff --git a/java-reporter-core/src/main/java/io/testomat/core/artifact/UploadedArtifactLinksStorage.java b/java-reporter-core/src/main/java/io/testomat/core/artifact/UploadedArtifactLinksStorage.java deleted file mode 100644 index cf9778a..0000000 --- a/java-reporter-core/src/main/java/io/testomat/core/artifact/UploadedArtifactLinksStorage.java +++ /dev/null @@ -1,20 +0,0 @@ -package io.testomat.core.artifact; - -import java.util.List; -import java.util.Map; -import java.util.concurrent.ConcurrentHashMap; - -public class UploadedArtifactLinksStorage { - public static final Map> LINK_STORAGE = new ConcurrentHashMap<>(); - - public static void store(String rid, List links) { - if (links.isEmpty()) { - return; - } - LINK_STORAGE.put(rid, links); - } - - public static Map> getLinkStorage() { - return LINK_STORAGE; - } -} diff --git a/java-reporter-core/src/main/java/io/testomat/core/artifact/client/AwsClient.java b/java-reporter-core/src/main/java/io/testomat/core/artifact/client/AwsClient.java index dc352d9..702d14c 100644 --- a/java-reporter-core/src/main/java/io/testomat/core/artifact/client/AwsClient.java +++ b/java-reporter-core/src/main/java/io/testomat/core/artifact/client/AwsClient.java @@ -1,6 +1,5 @@ package io.testomat.core.artifact.client; -import io.testomat.core.artifact.credential.CredentialsValidationService; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import software.amazon.awssdk.services.s3.S3Client; diff --git a/java-reporter-core/src/main/java/io/testomat/core/artifact/credential/CredentialsManager.java b/java-reporter-core/src/main/java/io/testomat/core/artifact/credential/CredentialsManager.java index 4de5b7d..d2d60be 100644 --- a/java-reporter-core/src/main/java/io/testomat/core/artifact/credential/CredentialsManager.java +++ b/java-reporter-core/src/main/java/io/testomat/core/artifact/credential/CredentialsManager.java @@ -17,10 +17,8 @@ import static io.testomat.core.constants.CredentialConstants.SECRET_ACCESS_KEY; import static io.testomat.core.constants.CredentialConstants.SHARED; -import io.testomat.core.exception.ArtifactManagementException; import io.testomat.core.propertyconfig.impl.PropertyProviderFactoryImpl; import io.testomat.core.propertyconfig.interf.PropertyProvider; -import java.lang.reflect.Field; import java.util.Map; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -29,13 +27,6 @@ public class CredentialsManager { private static final Logger log = LoggerFactory.getLogger(CredentialsManager.class); private static final S3Credentials credentials = new S3Credentials(); - private static final String SECRET_ACCESS_KEY_FIELD = "secretAccessKey"; - private static final String ACCESS_KEY_ID_FIELD = "accessKeyId"; - private static final String BUCKET_FIELD = "bucket"; - private static final String REGION_FIELD = "region"; - private static final String PRESIGN_FIELD = "presign"; - private static final String ENDPOINT_FIELD_NAME = "customEndpoint"; - private static final String FORCE_PATH_STYLE_FIELD = "forcePath"; private final PropertyProvider provider = PropertyProviderFactoryImpl.getPropertyProviderFactory().getPropertyProvider(); @@ -51,65 +42,29 @@ public void populateCredentials(Map credsFromServer) { log.warn("Received null or empty credentials map"); return; } - if (getPropertyFromEnv(FORCE_PATH_PROPERTY_NAME) != null) { - populateCredentialsFromEnv(getPropertyFromEnv(FORCE_PATH_PROPERTY_NAME), FORCE_PATH_STYLE_FIELD); - log.debug("ForcePath from env"); - } else { - credentials.setPresign(getBoolean(credsFromServer, FORCE_PATH, false)); - log.debug("ForcePath from server"); - } + populateCredentialField(FORCE_PATH_PROPERTY_NAME, FORCE_PATH, credsFromServer, "ForcePath", + value -> credentials.setForcePath(getBooleanValue(value))); + populateCredentialField(ENDPOINT_PROPERTY_NAME, ENDPOINT, credsFromServer, "Endpoint", + value -> credentials.setCustomEndpoint(getStringValue(value))); - if (getPropertyFromEnv(ENDPOINT_PROPERTY_NAME) != null) { - populateCredentialsFromEnv(getPropertyFromEnv(ENDPOINT_PROPERTY_NAME), ENDPOINT_FIELD_NAME); - log.debug("Endpoint from env"); - } else { - credentials.setCustomEndpoint(getString(credsFromServer, ENDPOINT)); - log.debug("Endpoint from server"); - } + populateCredentialField(PRIVATE_ARTIFACTS_PROPERTY_NAME, PRESIGN, credsFromServer, "Presign", + value -> credentials.setPresign(getBooleanValue(value))); - if (getPropertyFromEnv(PRIVATE_ARTIFACTS_PROPERTY_NAME) != null) { - populateCredentialsFromEnv(getPropertyFromEnv(PRIVATE_ARTIFACTS_PROPERTY_NAME), PRESIGN_FIELD); - log.debug("Presign from env"); - } else { - credentials.setPresign(getBoolean(credsFromServer, PRESIGN, false)); - log.debug("Presign from server"); - } + populateCredentialField(SECRET_ACCESS_KEY_PROPERTY_NAME, SECRET_ACCESS_KEY, credsFromServer, "SecretAccessKey", + value -> credentials.setSecretAccessKey(getStringValue(value))); - if (getPropertyFromEnv(SECRET_ACCESS_KEY_PROPERTY_NAME) != null) { - populateCredentialsFromEnv(getPropertyFromEnv(SECRET_ACCESS_KEY_PROPERTY_NAME), SECRET_ACCESS_KEY_FIELD); - log.debug("SecretAccessKey from env"); - } else { - credentials.setSecretAccessKey(getString(credsFromServer, SECRET_ACCESS_KEY)); - log.debug("SecretAccessKey from server"); - } + populateCredentialField(ACCESS_KEY_PROPERTY_NAME, ACCESS_KEY_ID, credsFromServer, "AccessKey", + value -> credentials.setAccessKeyId(getStringValue(value))); - if (getPropertyFromEnv(ACCESS_KEY_PROPERTY_NAME) != null) { - populateCredentialsFromEnv(getPropertyFromEnv(ACCESS_KEY_PROPERTY_NAME), ACCESS_KEY_ID_FIELD); - log.debug("AccessKey from env"); - } else { - credentials.setAccessKeyId(getString(credsFromServer, ACCESS_KEY_ID)); - log.debug("AccessKey from server"); - } + populateCredentialField(BUCKET_PROPERTY_NAME, BUCKET, credsFromServer, "Bucket", + value -> credentials.setBucket(getStringValue(value))); - if (getPropertyFromEnv(BUCKET_PROPERTY_NAME) != null) { - populateCredentialsFromEnv(getPropertyFromEnv(BUCKET_PROPERTY_NAME), BUCKET_FIELD); - log.debug("Bucket from env"); - } else { - credentials.setBucket(getString(credsFromServer, BUCKET)); - log.debug("Bucket from server"); - } + populateCredentialField(REGION_PROPERTY_NAME, REGION, credsFromServer, "Region", + value -> credentials.setRegion(getStringValue(value))); - if (getPropertyFromEnv(REGION_PROPERTY_NAME) != null) { - populateCredentialsFromEnv(getPropertyFromEnv(REGION_PROPERTY_NAME), REGION_FIELD); - log.debug("Region from env"); - } else { - credentials.setRegion(getString(credsFromServer, REGION)); - log.debug("Region from server"); - } - - credentials.setIam(getBoolean(credsFromServer, IAM, false)); - credentials.setShared(getBoolean(credsFromServer, SHARED, false)); + credentials.setIam(getBooleanValue(credsFromServer.get(IAM))); + credentials.setShared(getBooleanValue(credsFromServer.get(SHARED))); logCredentialsInitializationResult(); } @@ -133,16 +88,6 @@ private boolean areCredentialsAvailable() { return allAvailable; } - private boolean getBoolean(Map map, String key, boolean defaultValue) { - Object value = map.get(key); - return value != null ? Boolean.parseBoolean(value.toString()) : defaultValue; - } - - private String getString(Map map, String key) { - Object value = map.get(key); - return value != null ? value.toString() : null; - } - private Object getPropertyFromEnv(String propertyName) { try { return provider.getProperty(propertyName); @@ -151,20 +96,30 @@ private Object getPropertyFromEnv(String propertyName) { } } - private void populateCredentialsFromEnv(Object envPropertyValue, String fieldName) { - Field field; - try { - field = credentials.getClass().getDeclaredField(fieldName); - field.setAccessible(true); - field.set(credentials, envPropertyValue); - } catch (NoSuchFieldException e) { - throw new ArtifactManagementException("Failed to set credentials with reflection. " - + "The field does not exist: " + fieldName); - } catch (IllegalAccessException e) { - throw new ArtifactManagementException("Inaccessible field: " + fieldName); + private void populateCredentialField(String envPropertyName, String serverKey, + Map credsFromServer, String fieldDisplayName, + java.util.function.Consumer setter) { + Object envValue = getPropertyFromEnv(envPropertyName); + if (envValue != null) { + setter.accept(envValue); + log.debug("{} from env", fieldDisplayName); + } else { + Object serverValue = credsFromServer.get(serverKey); + if (serverValue != null) { + setter.accept(serverValue); + } + log.debug("{} from server", fieldDisplayName); } } + private String getStringValue(Object value) { + return value != null ? value.toString() : null; + } + + private boolean getBooleanValue(Object value) { + return value != null && Boolean.parseBoolean(value.toString()); + } + private void logCredentialsInitializationResult() { log.info("S3 credentials populated: bucket={}, region={}, presign={}, shared={}, iam={}", credentials.getBucket(), credentials.getRegion(), diff --git a/java-reporter-core/src/main/java/io/testomat/core/artifact/credential/CredentialsValidationService.java b/java-reporter-core/src/main/java/io/testomat/core/artifact/credential/CredentialsValidationService.java index 9cf3d16..ae5e2f0 100644 --- a/java-reporter-core/src/main/java/io/testomat/core/artifact/credential/CredentialsValidationService.java +++ b/java-reporter-core/src/main/java/io/testomat/core/artifact/credential/CredentialsValidationService.java @@ -1,16 +1,23 @@ package io.testomat.core.artifact.credential; +import io.testomat.core.artifact.client.AwsClient; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import software.amazon.awssdk.auth.credentials.AwsBasicCredentials; -import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider; -import software.amazon.awssdk.regions.Region; import software.amazon.awssdk.services.s3.S3Client; import software.amazon.awssdk.services.s3.model.HeadBucketRequest; import software.amazon.awssdk.services.s3.model.S3Exception; public class CredentialsValidationService { private static final Logger log = LoggerFactory.getLogger(CredentialsValidationService.class); + private final AwsClient awsClient; + + public CredentialsValidationService() { + this.awsClient = new AwsClient(); + } + + public CredentialsValidationService(AwsClient awsClient) { + this.awsClient = awsClient; + } public boolean areCredentialsValid(S3Credentials creds) { if (creds == null) { @@ -30,12 +37,8 @@ public boolean areCredentialsValid(S3Credentials creds) { log.debug("Validating S3 credentials for bucket: {} in region: {}", creds.getBucket(), creds.getRegion()); - try (S3Client client = S3Client.builder() - .credentialsProvider(StaticCredentialsProvider.create( - AwsBasicCredentials.create(creds.getAccessKeyId(), creds.getSecretAccessKey()))) - .region(Region.of(creds.getRegion())) - .build()) { - + try { + S3Client client = awsClient.getS3Client(); HeadBucketRequest headBucketRequest = HeadBucketRequest.builder() .bucket(creds.getBucket()) .build(); diff --git a/java-reporter-core/src/main/java/io/testomat/core/client/TestomatioClient.java b/java-reporter-core/src/main/java/io/testomat/core/client/TestomatioClient.java index 1a7e1ec..6402150 100644 --- a/java-reporter-core/src/main/java/io/testomat/core/client/TestomatioClient.java +++ b/java-reporter-core/src/main/java/io/testomat/core/client/TestomatioClient.java @@ -122,7 +122,8 @@ public void sendTestWithArtifacts(String uid) { log.debug("Preparing to secondary batch sending with artifacts"); String url = urlBuilder.buildReportTestUrl(uid); try { - String requestBody = requestBodyBuilder.buildBatchReportBodyWithArtifacts(ReportedTestStorage.getStorage(), apiKey); + String requestBody = requestBodyBuilder.buildBatchReportBodyWithArtifacts( + ReportedTestStorage.getStorage(), apiKey); client.post(url, requestBody, null); log.debug("Sent requestBody: {}", requestBody); } catch (Exception e) { @@ -147,13 +148,9 @@ public void finishTestRun(String uid, float duration) { public void uploadLinksToTestomatio(String uid) { - // String requestBody = linkUploadBodyBuilder.buildLinkUploadRequestBody( - // UploadedArtifactLinksStorage.getLinkStorage(), apiKey); String requestBody = linkUploadBodyBuilder.buildLinkUploadRequestBody( ArtifactLinkDataStorage.ARTEFACT_LINK_DATA_STORAGE, apiKey); - // if (UploadedArtifactLinksStorage.getLinkStorage().isEmpty()) { - // return; - // } + String url = urlBuilder.buildReportTestUrl(uid); log.debug("-> REQUEST BODY: {}", requestBody); From 05d4cc640392200968f8fb87c547365f881e28a4 Mon Sep 17 00:00:00 2001 From: Yevhenii Vlasenko Date: Mon, 22 Sep 2025 21:06:48 +0300 Subject: [PATCH 20/44] Added ACL check logic --- .../core/artifact/client/AwsService.java | 79 ++++++++++++++++--- 1 file changed, 70 insertions(+), 9 deletions(-) diff --git a/java-reporter-core/src/main/java/io/testomat/core/artifact/client/AwsService.java b/java-reporter-core/src/main/java/io/testomat/core/artifact/client/AwsService.java index 62069ea..a66ab4f 100644 --- a/java-reporter-core/src/main/java/io/testomat/core/artifact/client/AwsService.java +++ b/java-reporter-core/src/main/java/io/testomat/core/artifact/client/AwsService.java @@ -14,13 +14,18 @@ import java.nio.file.Paths; import java.util.ArrayList; import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import software.amazon.awssdk.core.sync.RequestBody; import software.amazon.awssdk.services.s3.model.PutObjectRequest; +import software.amazon.awssdk.services.s3.model.S3Exception; public class AwsService { private static final Logger log = LoggerFactory.getLogger(AwsService.class); + private static final Map bucketAclSupport = new ConcurrentHashMap<>(); + private final ArtifactKeyGenerator keyGenerator; private final ArtifactUrlGenerator urlGenerator; private final AwsClient awsClient; @@ -68,26 +73,82 @@ private void uploadArtifact(String dir, String key, S3Credentials credentials) { throw new ArtifactManagementException("Failed to read bytes from path: " + path); } - PutObjectRequest.Builder builder = PutObjectRequest.builder() - .bucket(credentials.getBucket()) - .key(key); + log.debug("Uploading to S3: bucket={}, key={}, size={} bytes", + credentials.getBucket(), key, content.length); - if (credentials.isPresign()) { - builder.acl("private"); + String bucketName = credentials.getBucket(); + Boolean supportsAcl = bucketAclSupport.get(bucketName); + + if (supportsAcl == null) { + boolean uploadSuccessful = tryUploadWithAcl(path, key, credentials, content); + if (!uploadSuccessful) { + bucketAclSupport.put(bucketName, false); + tryUploadWithoutAcl(path, key, credentials, content); + } else { + bucketAclSupport.put(bucketName, true); + } + } else if (supportsAcl) { + tryUploadWithAcl(path, key, credentials, content); } else { - builder.acl("public-read"); + tryUploadWithoutAcl(path, key, credentials, content); } - PutObjectRequest request = builder.build(); + } - log.debug("Uploading to S3: bucket={}, key={}, size={} bytes", - credentials.getBucket(), key, content.length); + private boolean tryUploadWithAcl(Path path, String key, S3Credentials credentials, byte[] content) { + try { + PutObjectRequest.Builder builder = PutObjectRequest.builder() + .bucket(credentials.getBucket()) + .key(key); + if (credentials.isPresign()) { + builder.acl("private"); + } else { + builder.acl("public-read"); + } + PutObjectRequest request = builder.build(); + + awsClient.getS3Client().putObject(request, RequestBody.fromBytes(content)); + log.debug("S3 upload completed successfully with ACL for file: {}", path); + return true; + } catch (S3Exception e) { + if (isAclNotSupportedError(e)) { + log.info("Bucket '{}' does not support ACLs, will retry without ACL", credentials.getBucket()); + return false; + } else { + log.error("S3 upload failed for file: {} to bucket: {}, key: {} - {} (Status: {})", + path, credentials.getBucket(), key, e.awsErrorDetails().errorMessage(), e.statusCode()); + throw new ArtifactManagementException("S3 upload failed: " + e.awsErrorDetails().errorMessage(), e); + } + } catch (Exception e) { + log.error("S3 upload failed for file: {} to bucket: {}, key: {}", path, credentials.getBucket(), key, e); + throw new ArtifactManagementException("S3 upload failed: " + e.getMessage(), e); + } + } + + private void tryUploadWithoutAcl(Path path, String key, S3Credentials credentials, byte[] content) { try { + PutObjectRequest request = PutObjectRequest.builder() + .bucket(credentials.getBucket()) + .key(key) + .build(); + awsClient.getS3Client().putObject(request, RequestBody.fromBytes(content)); log.info("S3 upload completed successfully for file: {}", path); + } catch (S3Exception e) { + log.error("S3 upload failed for file: {} to bucket: {}, key: {} - {} (Status: {})", + path, credentials.getBucket(), key, e.awsErrorDetails().errorMessage(), e.statusCode()); + throw new ArtifactManagementException("S3 upload failed: " + e.awsErrorDetails().errorMessage(), e); } catch (Exception e) { log.error("S3 upload failed for file: {} to bucket: {}, key: {}", path, credentials.getBucket(), key, e); throw new ArtifactManagementException("S3 upload failed: " + e.getMessage(), e); } } + + private boolean isAclNotSupportedError(S3Exception e) { + return (e.statusCode() == 400 && + ("AccessControlListNotSupported".equals(e.awsErrorDetails().errorCode()) || + "BucketMustHaveLockedConfiguration".equals(e.awsErrorDetails().errorCode()) || + (e.awsErrorDetails().errorMessage() != null && + e.awsErrorDetails().errorMessage().contains("does not allow ACLs")))); + } } From 24d8ae362b22494ac8a37f5bdea6833095281f97 Mon Sep 17 00:00:00 2001 From: Yevhenii Vlasenko Date: Mon, 22 Sep 2025 21:15:52 +0300 Subject: [PATCH 21/44] JavaDoc for artifact related classes --- .../io/testomat/core/artifact/ArtifactManager.java | 5 +++++ .../core/artifact/LinkUploadBodyBuilder.java | 5 +++++ .../artifact/TempArtifactDirectoriesStorage.java | 4 ++++ .../testomat/core/artifact/client/AwsClient.java | 8 ++++++-- .../testomat/core/artifact/client/AwsService.java | 11 +++++++++++ .../core/artifact/client/S3ClientFactory.java | 9 ++++++++- .../artifact/credential/CredentialsManager.java | 14 ++++++++++++++ .../credential/CredentialsValidationService.java | 10 ++++++++++ .../core/artifact/credential/S3Credentials.java | 4 ++++ .../core/artifact/util/ArtifactKeyGenerator.java | 4 ++++ .../core/artifact/util/ArtifactUrlGenerator.java | 4 ++++ 11 files changed, 75 insertions(+), 3 deletions(-) diff --git a/java-reporter-core/src/main/java/io/testomat/core/artifact/ArtifactManager.java b/java-reporter-core/src/main/java/io/testomat/core/artifact/ArtifactManager.java index d7d32b4..ef4fb49 100644 --- a/java-reporter-core/src/main/java/io/testomat/core/artifact/ArtifactManager.java +++ b/java-reporter-core/src/main/java/io/testomat/core/artifact/ArtifactManager.java @@ -1,5 +1,10 @@ package io.testomat.core.artifact; +/** + * Manager for handling test artifacts with path validation and storage. + * Provides secure file path validation and artifact registration for test runs. + */ + import io.testomat.core.exception.ArtifactManagementException; import java.nio.file.Files; import java.nio.file.InvalidPathException; diff --git a/java-reporter-core/src/main/java/io/testomat/core/artifact/LinkUploadBodyBuilder.java b/java-reporter-core/src/main/java/io/testomat/core/artifact/LinkUploadBodyBuilder.java index 3675234..302a8a9 100644 --- a/java-reporter-core/src/main/java/io/testomat/core/artifact/LinkUploadBodyBuilder.java +++ b/java-reporter-core/src/main/java/io/testomat/core/artifact/LinkUploadBodyBuilder.java @@ -1,5 +1,10 @@ package io.testomat.core.artifact; +/** + * Builder for creating JSON request bodies containing artifact links for upload to the server. + * Handles serialization of artifact data and test run information. + */ + import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.node.ArrayNode; diff --git a/java-reporter-core/src/main/java/io/testomat/core/artifact/TempArtifactDirectoriesStorage.java b/java-reporter-core/src/main/java/io/testomat/core/artifact/TempArtifactDirectoriesStorage.java index a625424..d2f24fe 100644 --- a/java-reporter-core/src/main/java/io/testomat/core/artifact/TempArtifactDirectoriesStorage.java +++ b/java-reporter-core/src/main/java/io/testomat/core/artifact/TempArtifactDirectoriesStorage.java @@ -3,6 +3,10 @@ import java.util.ArrayList; import java.util.List; +/** + * Thread-local storage for temporarily holding artifact file paths during test execution. + * Ensures thread safety when multiple tests run concurrently. + */ public class TempArtifactDirectoriesStorage { public static final ThreadLocal> DIRECTORIES = ThreadLocal.withInitial(ArrayList::new); diff --git a/java-reporter-core/src/main/java/io/testomat/core/artifact/client/AwsClient.java b/java-reporter-core/src/main/java/io/testomat/core/artifact/client/AwsClient.java index 702d14c..df4c0c5 100644 --- a/java-reporter-core/src/main/java/io/testomat/core/artifact/client/AwsClient.java +++ b/java-reporter-core/src/main/java/io/testomat/core/artifact/client/AwsClient.java @@ -4,6 +4,10 @@ import org.slf4j.LoggerFactory; import software.amazon.awssdk.services.s3.S3Client; +/** + * AWS S3 client wrapper with built-in configuration management. + * Provides singleton S3Client instances with custom endpoint and credential support. + */ public class AwsClient { private static final Logger log = LoggerFactory.getLogger(AwsClient.class); private volatile S3Client s3Client; @@ -21,9 +25,9 @@ public AwsClient(S3ClientFactory s3ClientFactory) { } /** - * Single copy getter + * Returns a configured S3Client instance using lazy initialization. * - * @return S3Client + * @return configured S3Client instance */ public S3Client getS3Client() { if (s3Client == null) { diff --git a/java-reporter-core/src/main/java/io/testomat/core/artifact/client/AwsService.java b/java-reporter-core/src/main/java/io/testomat/core/artifact/client/AwsService.java index a66ab4f..c51d216 100644 --- a/java-reporter-core/src/main/java/io/testomat/core/artifact/client/AwsService.java +++ b/java-reporter-core/src/main/java/io/testomat/core/artifact/client/AwsService.java @@ -22,6 +22,10 @@ import software.amazon.awssdk.services.s3.model.PutObjectRequest; import software.amazon.awssdk.services.s3.model.S3Exception; +/** + * Service for managing S3 artifact uploads with automatic ACL fallback support. + * Handles file uploads to S3 buckets with intelligent ACL detection and caching. + */ public class AwsService { private static final Logger log = LoggerFactory.getLogger(AwsService.class); private static final Map bucketAclSupport = new ConcurrentHashMap<>(); @@ -44,6 +48,13 @@ public AwsService(ArtifactKeyGenerator keyGenerator, AwsClient awsClient, this.urlGenerator = urlGenerator; } + /** + * Uploads all artifacts for a specific test to S3. + * + * @param testName the name of the test + * @param rid the request identifier + * @param testId the unique test identifier + */ public void uploadAllArtifactsForTest(String testName, String rid, String testId) { if (TempArtifactDirectoriesStorage.DIRECTORIES.get().isEmpty()) { log.debug("Artifact list is empty for test: {}", testName); diff --git a/java-reporter-core/src/main/java/io/testomat/core/artifact/client/S3ClientFactory.java b/java-reporter-core/src/main/java/io/testomat/core/artifact/client/S3ClientFactory.java index 094c370..8f8a0db 100644 --- a/java-reporter-core/src/main/java/io/testomat/core/artifact/client/S3ClientFactory.java +++ b/java-reporter-core/src/main/java/io/testomat/core/artifact/client/S3ClientFactory.java @@ -12,10 +12,17 @@ import software.amazon.awssdk.services.s3.S3Configuration; /** - * Factory для створення S3Client з підтримкою кастомних ендпоінтів + * Factory for creating configured S3Client instances with custom endpoint support. + * Handles AWS credentials, regions, and S3-compatible storage configurations. */ public class S3ClientFactory { + /** + * Creates a configured S3Client based on current credentials and settings. + * + * @return configured S3Client instance + * @throws IllegalArgumentException if credentials are invalid or missing + */ public S3Client createS3Client() { S3Credentials s3Credentials = CredentialsManager.getCredentials(); diff --git a/java-reporter-core/src/main/java/io/testomat/core/artifact/credential/CredentialsManager.java b/java-reporter-core/src/main/java/io/testomat/core/artifact/credential/CredentialsManager.java index d2d60be..5176603 100644 --- a/java-reporter-core/src/main/java/io/testomat/core/artifact/credential/CredentialsManager.java +++ b/java-reporter-core/src/main/java/io/testomat/core/artifact/credential/CredentialsManager.java @@ -23,6 +23,10 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; +/** + * Manages S3 credentials by combining environment variables and server-provided configuration. + * Provides centralized access to S3 credentials with priority given to environment variables. + */ public class CredentialsManager { private static final Logger log = LoggerFactory.getLogger(CredentialsManager.class); private static final S3Credentials credentials = new S3Credentials(); @@ -31,10 +35,20 @@ public class CredentialsManager { private final PropertyProvider provider = PropertyProviderFactoryImpl.getPropertyProviderFactory().getPropertyProvider(); + /** + * Returns the singleton S3Credentials instance. + * + * @return current S3 credentials + */ public static S3Credentials getCredentials() { return credentials; } + /** + * Populates S3 credentials from server response, with environment variables taking precedence. + * + * @param credsFromServer credentials map received from server + */ public void populateCredentials(Map credsFromServer) { log.debug("Populating S3 credentials"); diff --git a/java-reporter-core/src/main/java/io/testomat/core/artifact/credential/CredentialsValidationService.java b/java-reporter-core/src/main/java/io/testomat/core/artifact/credential/CredentialsValidationService.java index ae5e2f0..ed93507 100644 --- a/java-reporter-core/src/main/java/io/testomat/core/artifact/credential/CredentialsValidationService.java +++ b/java-reporter-core/src/main/java/io/testomat/core/artifact/credential/CredentialsValidationService.java @@ -7,6 +7,10 @@ import software.amazon.awssdk.services.s3.model.HeadBucketRequest; import software.amazon.awssdk.services.s3.model.S3Exception; +/** + * Service for validating S3 credentials by performing actual S3 operations. + * Uses HeadBucket operation to verify bucket access and credential validity. + */ public class CredentialsValidationService { private static final Logger log = LoggerFactory.getLogger(CredentialsValidationService.class); private final AwsClient awsClient; @@ -19,6 +23,12 @@ public CredentialsValidationService(AwsClient awsClient) { this.awsClient = awsClient; } + /** + * Validates S3 credentials by attempting a HeadBucket operation. + * + * @param creds the S3 credentials to validate + * @return true if credentials are valid and bucket is accessible, false otherwise + */ public boolean areCredentialsValid(S3Credentials creds) { if (creds == null) { log.error("Cannot validate null S3 credentials"); diff --git a/java-reporter-core/src/main/java/io/testomat/core/artifact/credential/S3Credentials.java b/java-reporter-core/src/main/java/io/testomat/core/artifact/credential/S3Credentials.java index 461123d..6d7c7d0 100644 --- a/java-reporter-core/src/main/java/io/testomat/core/artifact/credential/S3Credentials.java +++ b/java-reporter-core/src/main/java/io/testomat/core/artifact/credential/S3Credentials.java @@ -1,5 +1,9 @@ package io.testomat.core.artifact.credential; +/** + * Data class holding S3 configuration including credentials, bucket, and connection settings. + * Supports both AWS S3 and S3-compatible storage solutions. + */ public class S3Credentials { private boolean presign; private boolean shared; diff --git a/java-reporter-core/src/main/java/io/testomat/core/artifact/util/ArtifactKeyGenerator.java b/java-reporter-core/src/main/java/io/testomat/core/artifact/util/ArtifactKeyGenerator.java index 2d27b41..1462b7b 100644 --- a/java-reporter-core/src/main/java/io/testomat/core/artifact/util/ArtifactKeyGenerator.java +++ b/java-reporter-core/src/main/java/io/testomat/core/artifact/util/ArtifactKeyGenerator.java @@ -2,6 +2,10 @@ import java.nio.file.Paths; +/** + * Utility class for generating S3 object keys for artifact uploads. + * Creates hierarchical key structure based on run ID, test name, and file path. + */ public class ArtifactKeyGenerator { private static String runId; private static final String SEPARATOR = "/"; diff --git a/java-reporter-core/src/main/java/io/testomat/core/artifact/util/ArtifactUrlGenerator.java b/java-reporter-core/src/main/java/io/testomat/core/artifact/util/ArtifactUrlGenerator.java index e195356..5003d36 100644 --- a/java-reporter-core/src/main/java/io/testomat/core/artifact/util/ArtifactUrlGenerator.java +++ b/java-reporter-core/src/main/java/io/testomat/core/artifact/util/ArtifactUrlGenerator.java @@ -4,6 +4,10 @@ import software.amazon.awssdk.services.s3.model.GetUrlRequest; import software.amazon.awssdk.services.s3.presigner.S3Presigner; +/** + * Utility class for generating public and presigned URLs for S3 artifacts. + * Supports both direct S3 URLs and time-limited presigned URLs. + */ public class ArtifactUrlGenerator { private final AwsClient awsClient; From 231f60fb93d1838db8de038ed12a331e642e5eeb Mon Sep 17 00:00:00 2001 From: Yevhenii Vlasenko Date: Mon, 22 Sep 2025 21:20:11 +0300 Subject: [PATCH 22/44] JavaDoc for artifact related classes (missed in first commit) --- .../io/testomat/core/ArtifactLinkData.java | 37 +++++++++++++++++++ .../core/ArtifactLinkDataStorage.java | 4 ++ .../io/testomat/core/ReportedTestStorage.java | 19 ++++++++++ .../core/facade/ServiceRegistryUtil.java | 12 ++++++ .../io/testomat/core/facade/Testomatio.java | 9 +++++ 5 files changed, 81 insertions(+) diff --git a/java-reporter-core/src/main/java/io/testomat/core/ArtifactLinkData.java b/java-reporter-core/src/main/java/io/testomat/core/ArtifactLinkData.java index 3f7575e..8649c0b 100644 --- a/java-reporter-core/src/main/java/io/testomat/core/ArtifactLinkData.java +++ b/java-reporter-core/src/main/java/io/testomat/core/ArtifactLinkData.java @@ -2,6 +2,10 @@ import java.util.List; +/** + * Data class representing the relationship between test execution and its associated artifact links. + * Contains test metadata and S3 URLs for uploaded artifacts. + */ public class ArtifactLinkData { private String rid; private final String testId; @@ -9,6 +13,14 @@ public class ArtifactLinkData { private final List links; + /** + * Creates artifact link data for a test execution. + * + * @param testName name of the test + * @param rid request identifier + * @param testId unique test identifier + * @param links list of S3 URLs for uploaded artifacts + */ public ArtifactLinkData(String testName, String rid, String testId, List links) { this.testName = testName; this.rid = rid; @@ -16,22 +28,47 @@ public ArtifactLinkData(String testName, String rid, String testId, List this.links = links; } + /** + * Returns the list of artifact URLs. + * + * @return list of S3 URLs for artifacts + */ public List getLinks() { return links; } + /** + * Returns the unique test identifier. + * + * @return test ID + */ public String getTestId() { return testId; } + /** + * Returns the request identifier. + * + * @return request ID + */ public String getRid() { return rid; } + /** + * Sets the request identifier. + * + * @param rid request ID to set + */ public void setRid(String rid) { this.rid = rid; } + /** + * Returns the test name. + * + * @return name of the test + */ public String getTestName() { return testName; } diff --git a/java-reporter-core/src/main/java/io/testomat/core/ArtifactLinkDataStorage.java b/java-reporter-core/src/main/java/io/testomat/core/ArtifactLinkDataStorage.java index a0ac550..9a678ea 100644 --- a/java-reporter-core/src/main/java/io/testomat/core/ArtifactLinkDataStorage.java +++ b/java-reporter-core/src/main/java/io/testomat/core/ArtifactLinkDataStorage.java @@ -3,6 +3,10 @@ import java.util.List; import java.util.concurrent.CopyOnWriteArrayList; +/** + * Thread-safe storage for artifact link data collected during test execution. + * Maintains a collection of artifact links that will be associated with test results. + */ public class ArtifactLinkDataStorage { public static final List ARTEFACT_LINK_DATA_STORAGE = new CopyOnWriteArrayList<>(); diff --git a/java-reporter-core/src/main/java/io/testomat/core/ReportedTestStorage.java b/java-reporter-core/src/main/java/io/testomat/core/ReportedTestStorage.java index b7b04c2..0615467 100644 --- a/java-reporter-core/src/main/java/io/testomat/core/ReportedTestStorage.java +++ b/java-reporter-core/src/main/java/io/testomat/core/ReportedTestStorage.java @@ -4,17 +4,36 @@ import java.util.Map; import java.util.concurrent.CopyOnWriteArrayList; +/** + * Thread-safe storage for reported test data with artifact linking capabilities. + * Maintains test execution results and allows linking artifacts to specific tests by RID. + */ public class ReportedTestStorage { private static final List> STORAGE = new CopyOnWriteArrayList<>(); + /** + * Stores test execution data. + * + * @param body test data map containing test results and metadata + */ public static void store(Map body) { STORAGE.add(body); } + /** + * Returns all stored test data. + * + * @return list of test data maps + */ public static List> getStorage() { return STORAGE; } + /** + * Links artifacts to their corresponding tests using RID matching. + * + * @param artifactLinkData list of artifact link data to associate with tests + */ public static void linkArtifactsToTests(List artifactLinkData) { for (ArtifactLinkData data : artifactLinkData) { STORAGE.stream() diff --git a/java-reporter-core/src/main/java/io/testomat/core/facade/ServiceRegistryUtil.java b/java-reporter-core/src/main/java/io/testomat/core/facade/ServiceRegistryUtil.java index 3c4ebcf..9a50675 100644 --- a/java-reporter-core/src/main/java/io/testomat/core/facade/ServiceRegistryUtil.java +++ b/java-reporter-core/src/main/java/io/testomat/core/facade/ServiceRegistryUtil.java @@ -3,9 +3,21 @@ import java.util.Map; import java.util.concurrent.ConcurrentHashMap; +/** + * Service registry utility for managing singleton service instances. + * Provides thread-safe lazy initialization of services using reflection. + */ public class ServiceRegistryUtil { private static final Map, Object> services = new ConcurrentHashMap<>(); + /** + * Gets or creates a singleton instance of the specified service class. + * + * @param serviceClass the class of the service to retrieve + * @param the type of the service + * @return singleton instance of the service + * @throws RuntimeException if service cannot be instantiated + */ @SuppressWarnings("unchecked") public static T getService(Class serviceClass) { return (T) services.computeIfAbsent(serviceClass, clazz -> { diff --git a/java-reporter-core/src/main/java/io/testomat/core/facade/Testomatio.java b/java-reporter-core/src/main/java/io/testomat/core/facade/Testomatio.java index 123122b..2afcf73 100644 --- a/java-reporter-core/src/main/java/io/testomat/core/facade/Testomatio.java +++ b/java-reporter-core/src/main/java/io/testomat/core/facade/Testomatio.java @@ -2,8 +2,17 @@ import io.testomat.core.artifact.ArtifactManager; +/** + * Main public API facade for Testomat.io integration. + * Provides simple static methods for test artifact management and reporting. + */ public class Testomatio { + /** + * Registers artifact files or directories to be uploaded for the current test. + * + * @param directories paths to files or directories containing test artifacts + */ public static void artifact(String... directories) { ServiceRegistryUtil.getService(ArtifactManager.class).storeDirectories(directories); } From 9bfad79547cf1c47ec8bced0040f6794fb0276a7 Mon Sep 17 00:00:00 2001 From: Yevhenii Vlasenko Date: Tue, 23 Sep 2025 15:20:30 +0300 Subject: [PATCH 23/44] Added disable reporting logic --- .../core/constants/PropertyNameConstants.java | 2 ++ .../testomat/core/runmanager/GlobalRunManager.java | 14 +++++++++++++- 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/java-reporter-core/src/main/java/io/testomat/core/constants/PropertyNameConstants.java b/java-reporter-core/src/main/java/io/testomat/core/constants/PropertyNameConstants.java index 1698e12..3d662ca 100644 --- a/java-reporter-core/src/main/java/io/testomat/core/constants/PropertyNameConstants.java +++ b/java-reporter-core/src/main/java/io/testomat/core/constants/PropertyNameConstants.java @@ -3,6 +3,8 @@ public class PropertyNameConstants { public static final String PROPERTIES_FILE_NAME = "testomatio.properties"; + public static final String DISABLE_REPORTING_PROPERTY_NAME = "testomatio.reporting.disable"; + public static final String API_KEY_PROPERTY_NAME = "testomatio"; public static final String CREATE_TEST_PROPERTY_NAME = "testomatio.create"; public static final String HOST_URL_PROPERTY_NAME = "testomatio.url"; diff --git a/java-reporter-core/src/main/java/io/testomat/core/runmanager/GlobalRunManager.java b/java-reporter-core/src/main/java/io/testomat/core/runmanager/GlobalRunManager.java index c5df051..a3c9af2 100644 --- a/java-reporter-core/src/main/java/io/testomat/core/runmanager/GlobalRunManager.java +++ b/java-reporter-core/src/main/java/io/testomat/core/runmanager/GlobalRunManager.java @@ -1,6 +1,7 @@ package io.testomat.core.runmanager; import static io.testomat.core.constants.PropertyNameConstants.CUSTOM_RUN_UID_PROPERTY_NAME; +import static io.testomat.core.constants.PropertyNameConstants.DISABLE_REPORTING_PROPERTY_NAME; import static io.testomat.core.constants.PropertyNameConstants.RUN_TITLE_PROPERTY_NAME; import io.testomat.core.ArtifactLinkDataStorage; @@ -55,7 +56,7 @@ public static GlobalRunManager getInstance() { * Thread-safe operation that ensures single initialization. */ public synchronized void initializeIfNeeded() { - if (runUid.get() != null) { + if (runUid.get() != null || isReportingDisabled()) { return; } @@ -192,4 +193,15 @@ private String defineRunId(ApiInterface client) throws IOException { } return customUid; } + + private boolean isReportingDisabled() { + try { + return provider.getProperty(DISABLE_REPORTING_PROPERTY_NAME) != null + && !provider.getProperty(DISABLE_REPORTING_PROPERTY_NAME).trim().isEmpty() + && !provider.getProperty(DISABLE_REPORTING_PROPERTY_NAME).equalsIgnoreCase("0"); + } catch (Exception e) { + log.info("Reporting is disabled with testomatio.reporting.disabled=1"); + return false; + } + } } From 260a1dec14633414c112fc56f4eb01cf98df7f34 Mon Sep 17 00:00:00 2001 From: Yevhenii Vlasenko Date: Tue, 23 Sep 2025 15:20:58 +0300 Subject: [PATCH 24/44] Fixed isValidFilePath in ArtifactManager signature --- .../main/java/io/testomat/core/artifact/ArtifactManager.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/java-reporter-core/src/main/java/io/testomat/core/artifact/ArtifactManager.java b/java-reporter-core/src/main/java/io/testomat/core/artifact/ArtifactManager.java index ef4fb49..e3f57a6 100644 --- a/java-reporter-core/src/main/java/io/testomat/core/artifact/ArtifactManager.java +++ b/java-reporter-core/src/main/java/io/testomat/core/artifact/ArtifactManager.java @@ -26,7 +26,7 @@ public void storeDirectories(String... directories) { } } - public static boolean isValidFilePath(String filePath) { + private boolean isValidFilePath(String filePath) { if (filePath == null || filePath.trim().isEmpty()) { return false; } From a97ef00ae5dcf0c892896051e225a84e9f178346 Mon Sep 17 00:00:00 2001 From: Yevhenii Vlasenko Date: Tue, 23 Sep 2025 19:16:33 +0300 Subject: [PATCH 25/44] Refactored GlobalRunManager, added tests --- .../core/runmanager/GlobalRunManager.java | 133 +++++- .../core/runmanager/GlobalRunManagerTest.java | 430 ++++++++++++++++++ 2 files changed, 546 insertions(+), 17 deletions(-) create mode 100644 java-reporter-core/src/test/java/io/testomat/core/runmanager/GlobalRunManagerTest.java diff --git a/java-reporter-core/src/main/java/io/testomat/core/runmanager/GlobalRunManager.java b/java-reporter-core/src/main/java/io/testomat/core/runmanager/GlobalRunManager.java index a3c9af2..ecbca67 100644 --- a/java-reporter-core/src/main/java/io/testomat/core/runmanager/GlobalRunManager.java +++ b/java-reporter-core/src/main/java/io/testomat/core/runmanager/GlobalRunManager.java @@ -27,10 +27,11 @@ * Thread-safe implementation supporting concurrent test execution. */ public class GlobalRunManager { - private static final GlobalRunManager INSTANCE = new GlobalRunManager(); private static final Logger log = LoggerFactory.getLogger(GlobalRunManager.class); - private final PropertyProvider provider - = PropertyProviderFactoryImpl.getPropertyProviderFactory().getPropertyProvider(); + private static volatile GlobalRunManager INSTANCE; + + private final PropertyProvider provider; + private final ClientFactory clientFactory; private final AtomicInteger activeSuites = new AtomicInteger(0); private final AtomicReference runUid = new AtomicReference<>(); private final AtomicReference batchManager = new AtomicReference<>(); @@ -38,18 +39,66 @@ public class GlobalRunManager { private final AtomicBoolean shutdownHookRegistered = new AtomicBoolean(false); private volatile long startTime; + /** + * Default constructor that initializes all dependencies internally. + * Used for normal application runtime. + */ private GlobalRunManager() { + this.provider = PropertyProviderFactoryImpl.getPropertyProviderFactory().getPropertyProvider(); + this.clientFactory = TestomatClientFactory.getClientFactory(); + } + + /** + * Constructor for testing purposes that allows dependency injection. + * Package-private to allow testing while preventing external instantiation. + * + * @param provider the property provider to use + * @param clientFactory the client factory to use + */ + GlobalRunManager(PropertyProvider provider, ClientFactory clientFactory) { + this.provider = provider; + this.clientFactory = clientFactory; } /** * Returns the singleton instance of GlobalRunManager. + * Uses double-checked locking for thread-safe lazy initialization. * * @return the global run manager instance */ public static GlobalRunManager getInstance() { + if (INSTANCE == null) { + synchronized (GlobalRunManager.class) { + if (INSTANCE == null) { + INSTANCE = new GlobalRunManager(); + } + } + } return INSTANCE; } + /** + * Package-private method for testing to set a custom instance. + * Should only be used in test scenarios. + * + * @param instance the instance to set + */ + static void setInstance(GlobalRunManager instance) { + synchronized (GlobalRunManager.class) { + INSTANCE = instance; + } + } + + /** + * Package-private method for testing to reset the singleton instance. + * Should only be used in test scenarios. + */ + static void resetInstance() { + synchronized (GlobalRunManager.class) { + INSTANCE = null; + } + } + /** * Initializes test run if not already initialized. * Creates API client, test run UID, batch manager, and registers shutdown hook. @@ -61,10 +110,9 @@ public synchronized void initializeIfNeeded() { } try { - ClientFactory clientFactory = TestomatClientFactory.getClientFactory(); log.debug("Client factory initialized successfully"); - ApiInterface client = clientFactory.createClient(); + ApiInterface client = this.clientFactory.createClient(); log.debug("Client created successfully"); String uid = defineRunId(client); @@ -146,42 +194,93 @@ public boolean isActive() { return runUid.get() != null; } + /** + * Gets the current run UID if available. + * + * @return the current run UID or null if not initialized + */ + public String getRunUid() { + return runUid.get(); + } + + /** + * Gets the number of currently active test suites. + * + * @return number of active suites + */ + public int getActiveSuitesCount() { + return activeSuites.get(); + } + /** * Finalizes test run by shutting down batch manager and closing API connection. * Calculates run duration and sends completion notification to Testomat.io. */ private void finalizeRun() { - // apiClient.get().uploadLinksToTestomatio(runUid.get()); + finalizeRun(false); + } + + /** + * Finalizes test run with option to force finalization. + * + * @param force if true, forces finalization even if there are active suites + */ + private synchronized void finalizeRun(boolean force) { + if (!force && activeSuites.get() > 0) { + log.debug("Skipping finalization - {} active suites remaining", activeSuites.get()); + return; + } + + String uid = runUid.getAndSet(null); + if (uid == null) { + log.debug("Test run already finalized or not initialized"); + return; + } + BatchResultManager manager = batchManager.getAndSet(null); if (manager != null) { - manager.shutdown(); + try { + manager.shutdown(); + log.debug("Batch manager shutdown completed"); + } catch (Exception e) { + log.error("Error shutting down batch manager: {}", e.getMessage()); + } } - String uid = runUid.get(); - ApiInterface client = apiClient.get(); - - if (uid != null && client != null) { + ApiInterface client = apiClient.getAndSet(null); + if (client != null) { try { float duration = (System.currentTimeMillis() - startTime) / 1000.0f; client.finishTestRun(uid, duration); - log.debug("Test run finished: {}", uid); - ReportedTestStorage.linkArtifactsToTests(ArtifactLinkDataStorage.ARTEFACT_LINK_DATA_STORAGE); + log.debug("Test run finished: {} (duration: {}s)", uid, duration); - apiClient.get().sendTestWithArtifacts(runUid.get()); + ReportedTestStorage.linkArtifactsToTests(ArtifactLinkDataStorage.ARTEFACT_LINK_DATA_STORAGE); + client.sendTestWithArtifacts(uid); + log.debug("Artifacts sent successfully for run: {}", uid); } catch (IOException e) { - log.error("Failed to finish test run{}", String.valueOf(e.getCause())); + log.error("Failed to finish test run {}: {}", uid, e.getMessage()); + } catch (Exception e) { + log.error("Unexpected error during test run finalization {}: {}", uid, e.getMessage()); } } } + /** + * Forces immediate finalization of the test run. + * Should be used cautiously as it ignores active suites. + */ + public void forceFinalize() { + log.warn("Forcing test run finalization"); + finalizeRun(true); + } + /** * Retrieves test run title from properties. * * @return configured run title or null if not set */ private String getRunTitle() { - return PropertyProviderFactoryImpl.getPropertyProviderFactory() - .getPropertyProvider().getProperty(RUN_TITLE_PROPERTY_NAME); + return this.provider.getProperty(RUN_TITLE_PROPERTY_NAME); } private String defineRunId(ApiInterface client) throws IOException { diff --git a/java-reporter-core/src/test/java/io/testomat/core/runmanager/GlobalRunManagerTest.java b/java-reporter-core/src/test/java/io/testomat/core/runmanager/GlobalRunManagerTest.java new file mode 100644 index 0000000..60fafd0 --- /dev/null +++ b/java-reporter-core/src/test/java/io/testomat/core/runmanager/GlobalRunManagerTest.java @@ -0,0 +1,430 @@ +package io.testomat.core.runmanager; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.reset; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import io.testomat.core.batch.BatchResultManager; +import io.testomat.core.client.ApiInterface; +import io.testomat.core.client.ClientFactory; +import io.testomat.core.model.TestResult; +import io.testomat.core.propertyconfig.interf.PropertyProvider; +import java.io.IOException; +import java.lang.reflect.Field; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Timeout; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +@DisplayName("GlobalRunManager Tests") +class GlobalRunManagerTest { + + @Mock + private PropertyProvider mockPropertyProvider; + + @Mock + private ClientFactory mockClientFactory; + + @Mock + private ApiInterface mockApiClient; + + @Mock + private BatchResultManager mockBatchManager; + + private GlobalRunManager globalRunManager; + + @BeforeEach + void setUp() throws Exception { + // Reset singleton instance before each test + GlobalRunManager.resetInstance(); + + // Configure default mock behavior + lenient().when(mockClientFactory.createClient()).thenReturn(mockApiClient); + lenient().when(mockApiClient.createRun(any())).thenReturn("test-run-uid-123"); + lenient().when(mockPropertyProvider.getProperty("testomatio.reporting.disable")).thenReturn(null); + lenient().when(mockPropertyProvider.getProperty("testomatio.run.title")).thenReturn("Test Run"); + lenient().when(mockPropertyProvider.getProperty("testomatio.run.id")).thenThrow(new RuntimeException("Property not found")); + + // Create instance with mocked dependencies + globalRunManager = new GlobalRunManager(mockPropertyProvider, mockClientFactory); + GlobalRunManager.setInstance(globalRunManager); + } + + @AfterEach + void tearDown() { + GlobalRunManager.resetInstance(); + } + + @Nested + @DisplayName("Singleton Pattern Tests") + class SingletonPatternTests { + + @Test + @DisplayName("Should return same instance on multiple getInstance calls") + void shouldReturnSameInstance() { + // Reset to test lazy initialization + GlobalRunManager.resetInstance(); + + GlobalRunManager instance1 = GlobalRunManager.getInstance(); + GlobalRunManager instance2 = GlobalRunManager.getInstance(); + + assertNotNull(instance1); + assertNotNull(instance2); + assertSame(instance1, instance2); + } + + @Test + @DisplayName("Should be thread-safe during initialization") + @Timeout(5) + void shouldBeThreadSafeDuringInitialization() throws InterruptedException { + GlobalRunManager.resetInstance(); + + int threadCount = 10; + CountDownLatch startLatch = new CountDownLatch(1); + CountDownLatch endLatch = new CountDownLatch(threadCount); + AtomicReference[] instances = new AtomicReference[threadCount]; + + for (int i = 0; i < threadCount; i++) { + instances[i] = new AtomicReference<>(); + final int index = i; + new Thread(() -> { + try { + startLatch.await(); + instances[index].set(GlobalRunManager.getInstance()); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } finally { + endLatch.countDown(); + } + }).start(); + } + + startLatch.countDown(); + assertTrue(endLatch.await(3, TimeUnit.SECONDS)); + + GlobalRunManager firstInstance = instances[0].get(); + assertNotNull(firstInstance); + + for (int i = 1; i < threadCount; i++) { + assertSame(firstInstance, instances[i].get(), + "All instances should be the same object"); + } + } + } + + @Nested + @DisplayName("Initialization Tests") + class InitializationTests { + + @Test + @DisplayName("Should initialize successfully with valid configuration") + void shouldInitializeSuccessfully() throws IOException { + globalRunManager.initializeIfNeeded(); + + assertTrue(globalRunManager.isActive()); + assertEquals("test-run-uid-123", globalRunManager.getRunUid()); + + verify(mockClientFactory).createClient(); + verify(mockApiClient).createRun("Test Run"); + } + + @Test + @DisplayName("Should not initialize when reporting is disabled") + void shouldNotInitializeWhenReportingDisabled() { + when(mockPropertyProvider.getProperty("testomatio.reporting.disable")).thenReturn("1"); + + globalRunManager.initializeIfNeeded(); + + assertFalse(globalRunManager.isActive()); + assertNull(globalRunManager.getRunUid()); + + verify(mockClientFactory, never()).createClient(); + } + + @Test + @DisplayName("Should not initialize twice") + void shouldNotInitializeTwice() throws IOException { + globalRunManager.initializeIfNeeded(); + assertTrue(globalRunManager.isActive()); + + // Second initialization should be skipped + globalRunManager.initializeIfNeeded(); + + verify(mockClientFactory, times(1)).createClient(); + verify(mockApiClient, times(1)).createRun(any()); + } + + @Test + @DisplayName("Should use custom run ID when provided") + void shouldUseCustomRunId() throws IOException { + // Reset mocks to avoid stubbing conflicts + reset(mockPropertyProvider, mockClientFactory, mockApiClient); + + // Configure fresh stubbing for this test + lenient().when(mockPropertyProvider.getProperty("testomatio.reporting.disable")).thenReturn(null); + lenient().when(mockPropertyProvider.getProperty("testomatio.run.title")).thenReturn("Test Run"); + when(mockPropertyProvider.getProperty("testomatio.run.id")).thenReturn("custom-run-id"); + when(mockClientFactory.createClient()).thenReturn(mockApiClient); + + globalRunManager.initializeIfNeeded(); + + assertTrue(globalRunManager.isActive()); + assertEquals("custom-run-id", globalRunManager.getRunUid()); + + verify(mockApiClient, never()).createRun(any()); + } + + @Test + @DisplayName("Should handle initialization errors gracefully") + void shouldHandleInitializationErrors() throws IOException { + when(mockClientFactory.createClient()).thenThrow(new RuntimeException("Client creation failed")); + + globalRunManager.initializeIfNeeded(); + + assertFalse(globalRunManager.isActive()); + assertNull(globalRunManager.getRunUid()); + } + } + + @Nested + @DisplayName("Suite Counter Tests") + class SuiteCounterTests { + + @Test + @DisplayName("Should increment and decrement suite counter correctly") + void shouldManageSuiteCounterCorrectly() { + assertEquals(0, globalRunManager.getActiveSuitesCount()); + + globalRunManager.incrementSuiteCounter(); + assertEquals(1, globalRunManager.getActiveSuitesCount()); + + globalRunManager.incrementSuiteCounter(); + assertEquals(2, globalRunManager.getActiveSuitesCount()); + + globalRunManager.decrementSuiteCounter(); + assertEquals(1, globalRunManager.getActiveSuitesCount()); + + globalRunManager.decrementSuiteCounter(); + assertEquals(0, globalRunManager.getActiveSuitesCount()); + } + + @Test + @DisplayName("Should initialize when incrementing suite counter") + void shouldInitializeWhenIncrementingSuiteCounter() { + assertFalse(globalRunManager.isActive()); + + globalRunManager.incrementSuiteCounter(); + + assertTrue(globalRunManager.isActive()); + assertEquals(1, globalRunManager.getActiveSuitesCount()); + } + + @Test + @DisplayName("Should be thread-safe for concurrent suite operations") + @Timeout(5) + void shouldBeThreadSafeForConcurrentSuiteOperations() throws InterruptedException { + int threadCount = 10; + CountDownLatch startLatch = new CountDownLatch(1); + CountDownLatch endLatch = new CountDownLatch(threadCount * 2); + + // Start threads that increment + for (int i = 0; i < threadCount; i++) { + new Thread(() -> { + try { + startLatch.await(); + globalRunManager.incrementSuiteCounter(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } finally { + endLatch.countDown(); + } + }).start(); + } + + // Start threads that decrement + for (int i = 0; i < threadCount; i++) { + new Thread(() -> { + try { + startLatch.await(); + Thread.sleep(100); // Wait a bit to ensure some increments happen first + globalRunManager.decrementSuiteCounter(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } finally { + endLatch.countDown(); + } + }).start(); + } + + startLatch.countDown(); + assertTrue(endLatch.await(3, TimeUnit.SECONDS)); + + // Final count should be 0 (same number of increments and decrements) + assertEquals(0, globalRunManager.getActiveSuitesCount()); + } + } + + @Nested + @DisplayName("Test Reporting Tests") + class TestReportingTests { + + @Test + @DisplayName("Should report test when batch manager is available") + void shouldReportTestWhenBatchManagerAvailable() throws Exception { + // Initialize to set up batch manager + globalRunManager.initializeIfNeeded(); + + // Use reflection to inject mock batch manager + setBatchManager(mockBatchManager); + + TestResult testResult = new TestResult(); + testResult.setTestId("test-123"); + + globalRunManager.reportTest(testResult); + + verify(mockBatchManager).addResult(testResult); + } + + @Test + @DisplayName("Should handle test reporting when batch manager is null") + void shouldHandleTestReportingWhenBatchManagerNull() { + TestResult testResult = new TestResult(); + testResult.setTestId("test-123"); + + // Should not throw exception even when not initialized + assertDoesNotThrow(() -> globalRunManager.reportTest(testResult)); + } + + private void setBatchManager(BatchResultManager batchManager) throws Exception { + Field batchManagerField = GlobalRunManager.class.getDeclaredField("batchManager"); + batchManagerField.setAccessible(true); + AtomicReference batchManagerRef = + (AtomicReference) batchManagerField.get(globalRunManager); + batchManagerRef.set(batchManager); + } + } + + @Nested + @DisplayName("State Management Tests") + class StateManagementTests { + + @Test + @DisplayName("Should return correct active state") + void shouldReturnCorrectActiveState() { + assertFalse(globalRunManager.isActive()); + assertNull(globalRunManager.getRunUid()); + + globalRunManager.initializeIfNeeded(); + + assertTrue(globalRunManager.isActive()); + assertNotNull(globalRunManager.getRunUid()); + } + + @Test + @DisplayName("Should handle force finalization") + void shouldHandleForceFinalization() throws Exception { + globalRunManager.initializeIfNeeded(); + assertTrue(globalRunManager.isActive()); + + // Add active suites + globalRunManager.incrementSuiteCounter(); + globalRunManager.incrementSuiteCounter(); + assertEquals(2, globalRunManager.getActiveSuitesCount()); + + // Force finalization should work even with active suites + globalRunManager.forceFinalize(); + + assertFalse(globalRunManager.isActive()); + assertNull(globalRunManager.getRunUid()); + } + } + + @Nested + @DisplayName("Property Provider Tests") + class PropertyProviderTests { + + @Test + @DisplayName("Should handle reporting disabled with various values") + void shouldHandleReportingDisabledWithVariousValues() { + // Test with "1" + when(mockPropertyProvider.getProperty("testomatio.reporting.disable")).thenReturn("1"); + globalRunManager.initializeIfNeeded(); + assertFalse(globalRunManager.isActive()); + + // Reset and test with "true" + GlobalRunManager.resetInstance(); + globalRunManager = new GlobalRunManager(mockPropertyProvider, mockClientFactory); + GlobalRunManager.setInstance(globalRunManager); + when(mockPropertyProvider.getProperty("testomatio.reporting.disable")).thenReturn("true"); + globalRunManager.initializeIfNeeded(); + assertFalse(globalRunManager.isActive()); + + // Reset and test with "0" (should enable) + GlobalRunManager.resetInstance(); + globalRunManager = new GlobalRunManager(mockPropertyProvider, mockClientFactory); + GlobalRunManager.setInstance(globalRunManager); + when(mockPropertyProvider.getProperty("testomatio.reporting.disable")).thenReturn("0"); + globalRunManager.initializeIfNeeded(); + assertTrue(globalRunManager.isActive()); + } + + @Test + @DisplayName("Should handle missing properties gracefully") + void shouldHandleMissingPropertiesGracefully() { + // This test already passes with default stubbing from setUp() + // The default stubbing simulates the scenario where custom run ID is not found + globalRunManager.initializeIfNeeded(); + + // Should successfully initialize with auto-generated run ID + assertTrue(globalRunManager.isActive()); + assertEquals("test-run-uid-123", globalRunManager.getRunUid()); + } + } + + @Nested + @DisplayName("Error Handling Tests") + class ErrorHandlingTests { + + @Test + @DisplayName("Should handle API client creation failure") + void shouldHandleApiClientCreationFailure() throws IOException { + when(mockClientFactory.createClient()).thenThrow(new RuntimeException("Network error")); + + globalRunManager.initializeIfNeeded(); + + assertFalse(globalRunManager.isActive()); + assertNull(globalRunManager.getRunUid()); + } + + @Test + @DisplayName("Should handle run creation failure") + void shouldHandleRunCreationFailure() throws IOException { + when(mockApiClient.createRun(any())).thenThrow(new IOException("Server error")); + + globalRunManager.initializeIfNeeded(); + + assertFalse(globalRunManager.isActive()); + assertNull(globalRunManager.getRunUid()); + } + } +} \ No newline at end of file From ab27c1f62bb45e7ef061d87f9e22aa97a0f6de87 Mon Sep 17 00:00:00 2001 From: Yevhenii Vlasenko Date: Tue, 23 Sep 2025 19:37:46 +0300 Subject: [PATCH 26/44] Added artifact description to the README --- README.md | 73 +++++++++++++++++++++++++++++++++++++++- img/artifactExample.png | Bin 0 -> 55056 bytes 2 files changed, 72 insertions(+), 1 deletion(-) create mode 100644 img/artifactExample.png diff --git a/README.md b/README.md index 257bdb0..d39de16 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,7 @@ and team collaboration features. | **Advanced error reporting** | Detailed test failure/skip descriptions | ✅ | ✅ | ✅ | | **TestId import** | Import test IDs from testomat.io into the codebase | ✅ | ✅ | ✅ | | **Parametrized tests support** | Enhanced support for parameterized testing | ✅ | ✅ | ✅ | -| **Test artifacts support** | Screenshots, logs, and file attachments | ⏳ | ⏳ | ⏳ | +| **Test artifacts support** | Screenshots, logs, and file attachments | ✅ | ✅ | ✅ | | **Step-by-step reporting** | Detailed test step execution tracking | ⏳ | ⏳ | ⏳ | | **Other frameworks support** | Karate, Gauge, etc. (Priority may change) | | | | @@ -104,7 +104,9 @@ Create the `cucumber.properties` file if you don't have one yet and add this lin ```properties cucumber.plugin=io.testomat.cucumber.listener.CucumberListener ``` + --- + ### 🔧 Advanced Custom Setup > **⚠️ Only use this if you need custom behavior** - like adding extra logic to test lifecycle events. @@ -283,6 +285,75 @@ Use these oneliners to **download jar and update** ids in one move --- +## 📎 Test Artifacts Support + +The Java Reporter supports attaching files (screenshots, logs, videos, etc.) to your test results and uploading them to +S3-compatible storage. + +### Configuration + +Add these properties to your `testomatio.properties`: + +```properties +# S3 Configuration (can also be provided by Testomat.io server) +s3.bucket=your-bucket-name +s3.access.key.id=your-access-key +s3.secret.access.key.id=your-secret-key +s3.region=us-east-1 +s3.endpoint=https://s3.amazonaws.com +# Optional settings +s3.force.path.style=false +testomatio.private.artifacts=false +testomatio.artifact.max.size=10485760 +testomatio.disable.artifacts=false +``` + +**Note**: S3 credentials can be configured either in properties file or provided automatically on Testomat.io UI. +Environment variables take precedence over server-provided credentials. + +### Usage + +Use the `Testomatio` facade to attach files to your tests: + +```java +import io.testomat.core.facade.Testomatio; + +public class MyTest { + + @Test + public void testWithScreenshot() { + // Your test logic + + // Attach artifacts (screenshots, logs, etc.) + Testomatio.artifact( + "/path/to/screenshot.png", + "/path/to/test.log" + ); + } +} +``` +Multiple directories can be provided ti the `Testomatio.artifact(String ...)` facade method. + +### How It Works + +1. **File Validation**: Only existing files with valid paths are processed +2. **S3 Upload**: Files are uploaded to your S3 bucket with organized folder structure +3. **Link Generation**: Public URLs are generated and attached to test results +4. **Thread Safety**: Multiple concurrent tests can safely attach artifacts + +### Configuration Options + +| Setting | Description | Default | +|--------------------------------|-----------------------------------------------|---------| +| `testomatio.disable.artifacts` | Completely disable artifact uploading | `false` | +| `testomatio.private.artifacts` | Keep artifacts private (no public URLs) | `false` | +| `s3.force.path.style` | Use path-style URLs for S3-compatible storage | `false` | + +As the result you will see something like this on UI after run completed: +artifact example + +--- + ## 💡 Usage Examples ### Basic Usage diff --git a/img/artifactExample.png b/img/artifactExample.png new file mode 100644 index 0000000000000000000000000000000000000000..95e848ccbbb655c113b065e33aa74246457331f4 GIT binary patch literal 55056 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

!XA4Nl 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 literal 0 HcmV?d00001 From 3c7b45693427bffbc7d9cb74aaad88e0ac37847d Mon Sep 17 00:00:00 2001 From: Yevhenii Vlasenko Date: Wed, 24 Sep 2025 13:31:46 +0300 Subject: [PATCH 27/44] checkpoint --- .../io/testomat/core/ReportedTestStorage.java | 12 ++++- .../core/artifact/LinkUploadBodyBuilder.java | 1 + .../constants/PropertyValuesConstants.java | 2 +- .../junit/listener/JunitListener.java | 6 +-- java-reporter-testng/pom.xml | 8 ++-- .../extractor/TestNgMetaDataExtractor.java | 2 +- .../extractor/TestNgParameterExtractor.java | 15 ++++--- .../testng/listener/TestNgListener.java | 45 +++++++++++++++++-- 8 files changed, 71 insertions(+), 20 deletions(-) diff --git a/java-reporter-core/src/main/java/io/testomat/core/ReportedTestStorage.java b/java-reporter-core/src/main/java/io/testomat/core/ReportedTestStorage.java index 0615467..006e60b 100644 --- a/java-reporter-core/src/main/java/io/testomat/core/ReportedTestStorage.java +++ b/java-reporter-core/src/main/java/io/testomat/core/ReportedTestStorage.java @@ -3,12 +3,15 @@ import java.util.List; import java.util.Map; import java.util.concurrent.CopyOnWriteArrayList; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; /** * Thread-safe storage for reported test data with artifact linking capabilities. * Maintains test execution results and allows linking artifacts to specific tests by RID. */ public class ReportedTestStorage { + private static final Logger log = LoggerFactory.getLogger(ReportedTestStorage.class); private static final List> STORAGE = new CopyOnWriteArrayList<>(); /** @@ -18,6 +21,7 @@ public class ReportedTestStorage { */ public static void store(Map body) { STORAGE.add(body); + log.debug("Stored body: {}", body); } /** @@ -37,8 +41,12 @@ public static List> getStorage() { public static void linkArtifactsToTests(List artifactLinkData) { for (ArtifactLinkData data : artifactLinkData) { STORAGE.stream() - .filter(body -> data.getRid().equals(body.get("rid"))) - .forEach(body -> body.put("artifacts", data.getLinks())); + .filter(body -> data.getRid().equals(body.get("rid"))) + .forEach(body -> body.put("artifacts", data.getLinks())); + } + for (ArtifactLinkData data : artifactLinkData) { + log.debug("Linked: testId - {}, testName - {}, rid - {}, links - {}", + data.getTestId(), data.getTestName(), data.getRid(), data.getLinks().get(0)); } } } diff --git a/java-reporter-core/src/main/java/io/testomat/core/artifact/LinkUploadBodyBuilder.java b/java-reporter-core/src/main/java/io/testomat/core/artifact/LinkUploadBodyBuilder.java index 302a8a9..7ef37fe 100644 --- a/java-reporter-core/src/main/java/io/testomat/core/artifact/LinkUploadBodyBuilder.java +++ b/java-reporter-core/src/main/java/io/testomat/core/artifact/LinkUploadBodyBuilder.java @@ -28,6 +28,7 @@ public String buildLinkUploadRequestBody(List storedLinkData, testNode.put("rid", data.getRid()); testNode.put("test_id", data.getTestId()); testNode.put("title", data.getTestName()); + testNode.put("overwrite", "true"); testNode.set("artifacts", mapper.valueToTree(data.getLinks())); testsArray.add(testNode); } diff --git a/java-reporter-core/src/main/java/io/testomat/core/constants/PropertyValuesConstants.java b/java-reporter-core/src/main/java/io/testomat/core/constants/PropertyValuesConstants.java index 0e4b266..8f10fa8 100644 --- a/java-reporter-core/src/main/java/io/testomat/core/constants/PropertyValuesConstants.java +++ b/java-reporter-core/src/main/java/io/testomat/core/constants/PropertyValuesConstants.java @@ -2,7 +2,7 @@ public class PropertyValuesConstants { public static final int DEFAULT_BATCH_SIZE = 100000; - public static final int DEFAULT_FLUSH_INTERVAL_SECONDS = 60000; + public static final int DEFAULT_FLUSH_INTERVAL_SECONDS = 10; public static final String DEFAULT_URL = "https://app.testomat.io/"; public static final String DEFAULT_RUN_TITLE = "Default Run Title"; } diff --git a/java-reporter-junit/src/main/java/io/testomat/junit/listener/JunitListener.java b/java-reporter-junit/src/main/java/io/testomat/junit/listener/JunitListener.java index 0a7f229..ae42435 100644 --- a/java-reporter-junit/src/main/java/io/testomat/junit/listener/JunitListener.java +++ b/java-reporter-junit/src/main/java/io/testomat/junit/listener/JunitListener.java @@ -33,7 +33,7 @@ public class JunitListener implements BeforeEachCallback, BeforeAllCallback, private static final Logger log = LoggerFactory.getLogger(JunitListener.class); private static final String LISTENING_REQUIRED_PROPERTY_NAME = "testomatio.listening"; - private Boolean artifactEnabled; + private Boolean artifactDisabled; private final MethodExportManager methodExportManager; private final GlobalRunManager runManager; @@ -50,7 +50,7 @@ public JunitListener() { this.awsService = new AwsService(); this.provider = PropertyProviderFactoryImpl.getPropertyProviderFactory().getPropertyProvider(); - this.artifactEnabled = defineArtifactsDisabled(); + this.artifactDisabled = defineArtifactsDisabled(); } /** @@ -164,7 +164,7 @@ private boolean isListeningRequired() { @Override public void afterEach(ExtensionContext context) { - if (!artifactEnabled) { + if (!artifactDisabled) { awsService.uploadAllArtifactsForTest(context.getDisplayName(), context.getUniqueId(), JunitMetaDataExtractor.extractTestId(context.getTestMethod().get())); } diff --git a/java-reporter-testng/pom.xml b/java-reporter-testng/pom.xml index bf8b68c..7978044 100644 --- a/java-reporter-testng/pom.xml +++ b/java-reporter-testng/pom.xml @@ -4,9 +4,9 @@ xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> 4.0.0 - test + testngtest java-reporter-testng - test + testngtest jar Testomat.io Java Reporter TestNG @@ -44,9 +44,9 @@ - io.testomat + coretest java-reporter-core - 0.7.5 + coretest org.testng diff --git a/java-reporter-testng/src/main/java/io/testomat/testng/extractor/TestNgMetaDataExtractor.java b/java-reporter-testng/src/main/java/io/testomat/testng/extractor/TestNgMetaDataExtractor.java index d4eb506..afce8a4 100644 --- a/java-reporter-testng/src/main/java/io/testomat/testng/extractor/TestNgMetaDataExtractor.java +++ b/java-reporter-testng/src/main/java/io/testomat/testng/extractor/TestNgMetaDataExtractor.java @@ -55,7 +55,7 @@ private String getFilePath(Class testClass) { /** * Gets test ID from @TestId annotation. */ - private String getTestId(Method method) { + public String getTestId(Method method) { TestId testIdAnnotation = method.getAnnotation(TestId.class); return testIdAnnotation != null ? testIdAnnotation.value() : null; } diff --git a/java-reporter-testng/src/main/java/io/testomat/testng/extractor/TestNgParameterExtractor.java b/java-reporter-testng/src/main/java/io/testomat/testng/extractor/TestNgParameterExtractor.java index 3255a13..6f90edb 100644 --- a/java-reporter-testng/src/main/java/io/testomat/testng/extractor/TestNgParameterExtractor.java +++ b/java-reporter-testng/src/main/java/io/testomat/testng/extractor/TestNgParameterExtractor.java @@ -122,24 +122,27 @@ private String[] getParameterNamesFromAnnotation(Method method) { } /** - * Generates unique RID (Run ID) for parameterized test based on parameters. + * Generates unique RID (Run ID) for test based on class name, method name and parameters. * Uses readable parameter values when possible. + * For non-parameterized tests, returns className.methodName. * * @param testResult TestNG test result containing parameters - * @return unique RID string or null if no parameters + * @return unique RID string (never null) */ public String generateRid(ITestResult testResult) { + StringBuilder ridBuilder = new StringBuilder(); + ridBuilder.append(testResult.getTestClass().getName()) + .append(".") + .append(testResult.getMethod().getMethodName()); + Object[] parameters = testResult.getParameters(); if (parameters == null || parameters.length == 0) { - return null; + return ridBuilder.toString(); } Method method = testResult.getMethod().getConstructorOrMethod().getMethod(); String[] parameterNames = extractParameterNames(method, parameters.length); - StringBuilder ridBuilder = new StringBuilder(); - ridBuilder.append(testResult.getMethod().getMethodName()); - for (int i = 0; i < parameters.length; i++) { Object param = parameters[i]; String paramString = param != null ? param.toString() : "null"; diff --git a/java-reporter-testng/src/main/java/io/testomat/testng/listener/TestNgListener.java b/java-reporter-testng/src/main/java/io/testomat/testng/listener/TestNgListener.java index 50f5b0d..38ffb0c 100644 --- a/java-reporter-testng/src/main/java/io/testomat/testng/listener/TestNgListener.java +++ b/java-reporter-testng/src/main/java/io/testomat/testng/listener/TestNgListener.java @@ -1,12 +1,16 @@ package io.testomat.testng.listener; +import static io.testomat.core.constants.ArtifactPropertyNames.ARTIFACT_DISABLE_PROPERTY_NAME; import static io.testomat.core.constants.CommonConstants.FAILED; import static io.testomat.core.constants.CommonConstants.PASSED; import static io.testomat.core.constants.CommonConstants.SKIPPED; +import io.testomat.core.artifact.client.AwsService; import io.testomat.core.propertyconfig.impl.PropertyProviderFactoryImpl; import io.testomat.core.propertyconfig.interf.PropertyProvider; import io.testomat.core.runmanager.GlobalRunManager; +import io.testomat.testng.extractor.TestNgMetaDataExtractor; +import io.testomat.testng.extractor.TestNgParameterExtractor; import io.testomat.testng.filter.TestIdFilter; import io.testomat.testng.methodexporter.TestNgMethodExportManager; import io.testomat.testng.reporter.TestNgTestResultReporter; @@ -40,7 +44,13 @@ public class TestNgListener implements ISuiteListener, ITestListener, private final Set processedClasses; private final TestIdFilter testIdFilter; + private final AwsService awsService; + private final TestNgParameterExtractor testNgParameterExtractor; + private final TestNgMetaDataExtractor metaDataExtractor; + public TestNgListener() { + this.metaDataExtractor = new TestNgMetaDataExtractor(); + this.testNgParameterExtractor = new TestNgParameterExtractor(); this.methodExportManager = new TestNgMethodExportManager(); this.processedClasses = ConcurrentHashMap.newKeySet(); this.runManager = GlobalRunManager.getInstance(); @@ -48,6 +58,7 @@ public TestNgListener() { this.provider = PropertyProviderFactoryImpl.getPropertyProviderFactory().getPropertyProvider(); this.testIdFilter = new TestIdFilter(); + this.awsService = new AwsService(); } /** @@ -56,13 +67,20 @@ public TestNgListener() { public TestNgListener(TestNgMethodExportManager methodExportManager, TestNgTestResultReporter reporter, GlobalRunManager runManager, - PropertyProvider provider) { + PropertyProvider provider, + AwsService awsService, + TestIdFilter testIdFilter, + TestNgParameterExtractor testNgParameterExtractor, + TestNgMetaDataExtractor metaDataExtractor) { this.runManager = runManager; this.reporter = reporter; this.methodExportManager = methodExportManager; this.provider = provider; + this.testNgParameterExtractor = testNgParameterExtractor; + this.metaDataExtractor = metaDataExtractor; this.processedClasses = ConcurrentHashMap.newKeySet(); - this.testIdFilter = new TestIdFilter(); + this.testIdFilter = testIdFilter; + this.awsService = awsService; } @Override @@ -156,7 +174,13 @@ private void exportTestClassIfNotProcessed(Class testClass) { @Override public void afterInvocation(IInvokedMethod method, ITestResult testResult) { - + if (method.isTestMethod() && !defineArtifactsDisabled()) { + awsService.uploadAllArtifactsForTest(testResult.getTestName(), + testNgParameterExtractor.generateRid(testResult), + metaDataExtractor.getTestId( + method.getTestMethod().getConstructorOrMethod().getMethod()) + ); + } } private boolean isListeningRequired() { @@ -166,4 +190,19 @@ private boolean isListeningRequired() { return false; } } + + private boolean defineArtifactsDisabled() { + boolean result; + String property; + try { + property = provider.getProperty(ARTIFACT_DISABLE_PROPERTY_NAME); + result = property != null + && !property.trim().isEmpty() + && !property.equalsIgnoreCase("0"); + + } catch (Exception e) { + return false; + } + return result; + } } From 585c29b1aefc97b8240cb3c146abcfee9574771d Mon Sep 17 00:00:00 2001 From: Yevhenii Vlasenko Date: Wed, 24 Sep 2025 15:35:06 +0300 Subject: [PATCH 28/44] fixed TestNg artifact bugs --- .../core/client/request/NativeRequestBodyBuilder.java | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/java-reporter-core/src/main/java/io/testomat/core/client/request/NativeRequestBodyBuilder.java b/java-reporter-core/src/main/java/io/testomat/core/client/request/NativeRequestBodyBuilder.java index eda4c4d..c9ee89d 100644 --- a/java-reporter-core/src/main/java/io/testomat/core/client/request/NativeRequestBodyBuilder.java +++ b/java-reporter-core/src/main/java/io/testomat/core/client/request/NativeRequestBodyBuilder.java @@ -70,6 +70,8 @@ public String buildCreateRunBody(String title) { body.put("access_event", "publish"); } + body.put("overwrite", "true"); + return objectMapper.writeValueAsString(body); } catch (JsonProcessingException e) { @@ -157,6 +159,8 @@ private Map buildTestResultMap(TestResult result) { if (createParam) { body.put("create", TRUE); } + + body.put("overwrite", "true"); ReportedTestStorage.store(body); return body; } From 70ac82515ef5b79c18b460fc84291b955a509b51 Mon Sep 17 00:00:00 2001 From: Yevhenii Vlasenko Date: Fri, 26 Sep 2025 13:30:52 +0300 Subject: [PATCH 29/44] Added the delay before artifact batch sending --- .../src/main/java/io/testomat/core/client/TestomatioClient.java | 1 + .../main/java/io/testomat/core/runmanager/GlobalRunManager.java | 1 + 2 files changed, 2 insertions(+) diff --git a/java-reporter-core/src/main/java/io/testomat/core/client/TestomatioClient.java b/java-reporter-core/src/main/java/io/testomat/core/client/TestomatioClient.java index 6402150..0c039f9 100644 --- a/java-reporter-core/src/main/java/io/testomat/core/client/TestomatioClient.java +++ b/java-reporter-core/src/main/java/io/testomat/core/client/TestomatioClient.java @@ -109,6 +109,7 @@ public void reportTests(String uid, List results) { String url = urlBuilder.buildReportTestUrl(uid); String requestBody = requestBodyBuilder.buildBatchTestReportBody(results, apiKey); + log.debug("Batch request body: {}", requestBody); client.post(url, requestBody, null); } catch (Exception e) { diff --git a/java-reporter-core/src/main/java/io/testomat/core/runmanager/GlobalRunManager.java b/java-reporter-core/src/main/java/io/testomat/core/runmanager/GlobalRunManager.java index ecbca67..fc48431 100644 --- a/java-reporter-core/src/main/java/io/testomat/core/runmanager/GlobalRunManager.java +++ b/java-reporter-core/src/main/java/io/testomat/core/runmanager/GlobalRunManager.java @@ -255,6 +255,7 @@ private synchronized void finalizeRun(boolean force) { log.debug("Test run finished: {} (duration: {}s)", uid, duration); ReportedTestStorage.linkArtifactsToTests(ArtifactLinkDataStorage.ARTEFACT_LINK_DATA_STORAGE); + Thread.sleep(15000); client.sendTestWithArtifacts(uid); log.debug("Artifacts sent successfully for run: {}", uid); } catch (IOException e) { From 6576b47bdbc1fb58787b2f5976e9494dc04daea8 Mon Sep 17 00:00:00 2001 From: Yevhenii Vlasenko Date: Sun, 28 Sep 2025 21:22:09 +0300 Subject: [PATCH 30/44] Fixed artifact paths for all frameworks --- .../artifact/util/ArtifactKeyGenerator.java | 1 + java-reporter-cucumber/pom.xml | 8 +- .../CucumberTestResultConstructor.java | 5 +- .../cucumber/extractor/TestDataExtractor.java | 17 +- .../cucumber/listener/CucumberListener.java | 26 +- .../listener/CucumberListenerTest.java | 468 +++++++++--------- .../testng/listener/TestNgListener.java | 2 +- 7 files changed, 269 insertions(+), 258 deletions(-) diff --git a/java-reporter-core/src/main/java/io/testomat/core/artifact/util/ArtifactKeyGenerator.java b/java-reporter-core/src/main/java/io/testomat/core/artifact/util/ArtifactKeyGenerator.java index 1462b7b..a2b1158 100644 --- a/java-reporter-core/src/main/java/io/testomat/core/artifact/util/ArtifactKeyGenerator.java +++ b/java-reporter-core/src/main/java/io/testomat/core/artifact/util/ArtifactKeyGenerator.java @@ -17,6 +17,7 @@ public static void initializeRunId(Object runId) { public String generateKey(String dir, String rid, String testName) { return runId + SEPARATOR + +testName + "::" + rid + SEPARATOR + Paths.get(dir).getFileName(); diff --git a/java-reporter-cucumber/pom.xml b/java-reporter-cucumber/pom.xml index 44d768c..b8c8d31 100644 --- a/java-reporter-cucumber/pom.xml +++ b/java-reporter-cucumber/pom.xml @@ -4,9 +4,9 @@ xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> 4.0.0 - io.testomat + cucumbertest java-reporter-cucumber - 0.7.2 + cucumbertest jar Testomat.io Java Reporter Cucumber @@ -48,9 +48,9 @@ - io.testomat + coretest java-reporter-core - 0.7.2 + coretest io.cucumber diff --git a/java-reporter-cucumber/src/main/java/io/testomat/cucumber/constructor/CucumberTestResultConstructor.java b/java-reporter-cucumber/src/main/java/io/testomat/cucumber/constructor/CucumberTestResultConstructor.java index bc97814..8ab1ffb 100644 --- a/java-reporter-cucumber/src/main/java/io/testomat/cucumber/constructor/CucumberTestResultConstructor.java +++ b/java-reporter-cucumber/src/main/java/io/testomat/cucumber/constructor/CucumberTestResultConstructor.java @@ -40,12 +40,15 @@ public TestResult constructTestRunResult(TestCaseFinished event) { ExceptionDetails exceptionDetails = testDataExtractor.extractExceptionDetails(event); + String fileName = testDataExtractor.extractFileName(event); + System.out.println("CucumberTestResultConstructor: extractFileName returned: " + fileName); + return TestResult.builder() .withStatus(testDataExtractor.getNormalizedStatus(event)) .withSuiteTitle(event.getTestCase().getUri().toString()) .withExample(testDataExtractor.createExample(event)) .withTestId(testDataExtractor.extractTestId(event)) - .withFile(testDataExtractor.extractFileName(event)) + .withFile(fileName) .withTitle(testDataExtractor.extractTitle(event)) .withRid(event.getTestCase().getId().toString()) .withMessage(exceptionDetails.getMessage()) diff --git a/java-reporter-cucumber/src/main/java/io/testomat/cucumber/extractor/TestDataExtractor.java b/java-reporter-cucumber/src/main/java/io/testomat/cucumber/extractor/TestDataExtractor.java index e932b78..fcb4330 100644 --- a/java-reporter-cucumber/src/main/java/io/testomat/cucumber/extractor/TestDataExtractor.java +++ b/java-reporter-cucumber/src/main/java/io/testomat/cucumber/extractor/TestDataExtractor.java @@ -114,22 +114,7 @@ public String extractTitle(TestCaseFinished event) { * @return feature file name, null if extraction fails */ public String extractFileName(TestCaseFinished event) { - try { - String path = event.getTestCase().getUri().getPath(); - - if (path.contains(":") && !path.startsWith("/")) { - int colonIndex = path.indexOf(":"); - if (colonIndex < path.length() - 1) { - path = path.substring(colonIndex + 1); - } - } - - int lastSlash = path.lastIndexOf('/'); - return lastSlash != -1 ? path.substring(lastSlash + 1) : path; - - } catch (Exception e) { - return null; - } + return event.getTestCase().getUri().toString(); } /** diff --git a/java-reporter-cucumber/src/main/java/io/testomat/cucumber/listener/CucumberListener.java b/java-reporter-cucumber/src/main/java/io/testomat/cucumber/listener/CucumberListener.java index 5f812ef..59643f1 100644 --- a/java-reporter-cucumber/src/main/java/io/testomat/cucumber/listener/CucumberListener.java +++ b/java-reporter-cucumber/src/main/java/io/testomat/cucumber/listener/CucumberListener.java @@ -6,11 +6,13 @@ import io.cucumber.plugin.event.TestCaseFinished; import io.cucumber.plugin.event.TestRunFinished; import io.cucumber.plugin.event.TestRunStarted; +import io.testomat.core.artifact.client.AwsService; import io.testomat.core.exception.ReportTestResultException; import io.testomat.core.model.TestResult; import io.testomat.core.runmanager.GlobalRunManager; import io.testomat.cucumber.constructor.CucumberTestResultConstructor; import io.testomat.cucumber.exception.CucumberListenerException; +import io.testomat.cucumber.extractor.TestDataExtractor; /** * Cucumber plugin for Testomat.io integration. @@ -19,13 +21,17 @@ public class CucumberListener implements Plugin, EventListener { private final GlobalRunManager runManager; private final CucumberTestResultConstructor resultConstructor; + private final AwsService awsService; + private final TestDataExtractor dataExtractor; /** * Creates a new listener with default dependencies. */ public CucumberListener() { + this.dataExtractor = new TestDataExtractor(); this.runManager = GlobalRunManager.getInstance(); this.resultConstructor = new CucumberTestResultConstructor(); + this.awsService = new AwsService(); } /** @@ -33,12 +39,15 @@ public CucumberListener() { * Used primarily for testing with mocked dependencies. * * @param resultConstructor the test result constructor - * @param runManager the global run manager + * @param runManager the global run manager */ public CucumberListener(CucumberTestResultConstructor resultConstructor, - GlobalRunManager runManager) { + GlobalRunManager runManager, + AwsService awsService, TestDataExtractor dataExtractor) { this.runManager = runManager; this.resultConstructor = resultConstructor; + this.awsService = awsService; + this.dataExtractor = dataExtractor; } /** @@ -68,10 +77,23 @@ void handleTestCaseFinished(TestCaseFinished event) { try { TestResult result = resultConstructor.constructTestRunResult(event); runManager.reportTest(result); + afterEach(event); } catch (Exception e) { String testName = event.getTestCase() != null ? event.getTestCase().getName() : "Unknown Test"; throw new ReportTestResultException("Failed to report test result for: " + testName, e); } } + + /** + * Called after each test case execution, similar to JUnit/TestNG afterEach. + * Override this method to add custom post-test logic. + * + * @param event the test case finished event + */ + protected void afterEach(TestCaseFinished event) { + awsService.uploadAllArtifactsForTest(dataExtractor.extractTitle(event), + event.getTestCase().getId().toString(), + dataExtractor.extractTestId(event)); + } } diff --git a/java-reporter-cucumber/src/test/java/io/testomat/cucumber/listener/CucumberListenerTest.java b/java-reporter-cucumber/src/test/java/io/testomat/cucumber/listener/CucumberListenerTest.java index 5be3777..2d6be00 100644 --- a/java-reporter-cucumber/src/test/java/io/testomat/cucumber/listener/CucumberListenerTest.java +++ b/java-reporter-cucumber/src/test/java/io/testomat/cucumber/listener/CucumberListenerTest.java @@ -1,234 +1,234 @@ -package io.testomat.cucumber.listener; - -import io.cucumber.plugin.event.EventPublisher; -import io.cucumber.plugin.event.TestCase; -import io.cucumber.plugin.event.TestCaseFinished; -import io.cucumber.plugin.event.TestRunFinished; -import io.cucumber.plugin.event.TestRunStarted; -import io.testomat.core.exception.ReportTestResultException; -import io.testomat.core.model.TestResult; -import io.testomat.core.runmanager.GlobalRunManager; -import io.testomat.cucumber.constructor.CucumberTestResultConstructor; -import io.testomat.cucumber.exception.CucumberListenerException; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.mockito.Mock; -import org.mockito.MockitoAnnotations; - -import static org.junit.jupiter.api.Assertions.*; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.Mockito.*; - -class CucumberListenerTest { - - @Mock - private CucumberTestResultConstructor resultConstructor; - - @Mock - private GlobalRunManager runManager; - - @Mock - private EventPublisher eventPublisher; - - @Mock - private TestCaseFinished testCaseFinished; - - @Mock - private TestCase testCase; - - @Mock - private TestResult testResult; - - @Mock - private TestRunStarted testRunStarted; - - @Mock - private TestRunFinished testRunFinished; - - private CucumberListener listener; - - @BeforeEach - void setUp() { - MockitoAnnotations.openMocks(this); - listener = new CucumberListener(resultConstructor, runManager); - } - - @Test - void shouldCreateDefaultConstructor() { - CucumberListener defaultListener = new CucumberListener(); - assertNotNull(defaultListener); - } - - @Test - void shouldRegisterEventHandlers() { - // When - listener.setEventPublisher(eventPublisher); - - // Then - verify(eventPublisher).registerHandlerFor(eq(TestRunStarted.class), any()); - verify(eventPublisher).registerHandlerFor(eq(TestRunFinished.class), any()); - verify(eventPublisher).registerHandlerFor(eq(TestCaseFinished.class), any()); - } - - @Test - void shouldIncrementSuiteCounterOnTestRunStarted() { - // Given - when(runManager.isActive()).thenReturn(true); - listener.setEventPublisher(eventPublisher); - - // Verify that the handler was registered - verify(eventPublisher).registerHandlerFor(eq(TestRunStarted.class), any()); - - // We can't easily test the lambda directly, but we can verify the registration - verify(runManager, never()).incrementSuiteCounter(); - } - - @Test - void shouldDecrementSuiteCounterOnTestRunFinished() { - // Given - when(runManager.isActive()).thenReturn(true); - listener.setEventPublisher(eventPublisher); - - // Verify that the handler was registered - verify(eventPublisher).registerHandlerFor(eq(TestRunFinished.class), any()); - - // We can't easily test the lambda directly, but we can verify the registration - verify(runManager, never()).decrementSuiteCounter(); - } - - @Test - void shouldHandleTestCaseFinishedWhenRunManagerIsActive() { - // Given - when(runManager.isActive()).thenReturn(true); - when(testCaseFinished.getTestCase()).thenReturn(testCase); - when(testCase.getName()).thenReturn("Test Scenario"); - when(resultConstructor.constructTestRunResult(testCaseFinished)).thenReturn(testResult); - - // When - listener.handleTestCaseFinished(testCaseFinished); - - // Then - verify(resultConstructor).constructTestRunResult(testCaseFinished); - verify(runManager).reportTest(testResult); - } - - @Test - void shouldNotHandleTestCaseFinishedWhenRunManagerIsInactive() { - // Given - when(runManager.isActive()).thenReturn(false); - - // When - listener.handleTestCaseFinished(testCaseFinished); - - // Then - verify(resultConstructor, never()).constructTestRunResult(any()); - verify(runManager, never()).reportTest(any()); - } - - @Test - void shouldThrowExceptionWhenEventIsNull() { - // Given - when(runManager.isActive()).thenReturn(true); - - // When & Then - CucumberListenerException exception = assertThrows( - CucumberListenerException.class, - () -> listener.handleTestCaseFinished(null) - ); - - assertEquals("The listener received null event", exception.getMessage()); - } - - @Test - void shouldWrapExceptionWhenConstructorFails() { - // Given - RuntimeException originalException = new RuntimeException("Constructor error"); - when(runManager.isActive()).thenReturn(true); - when(testCaseFinished.getTestCase()).thenReturn(testCase); - when(testCase.getName()).thenReturn("Test Scenario"); - when(resultConstructor.constructTestRunResult(testCaseFinished)) - .thenThrow(originalException); - - // When & Then - ReportTestResultException exception = assertThrows( - ReportTestResultException.class, - () -> listener.handleTestCaseFinished(testCaseFinished) - ); - - assertTrue(exception.getMessage().contains("Failed to report test result for: Test Scenario")); - assertNull(exception.getCause()); // ReportTestResultException doesn't chain the cause properly - } - - @Test - void shouldWrapExceptionWhenReportTestFails() { - // Given - RuntimeException originalException = new RuntimeException("Report error"); - when(runManager.isActive()).thenReturn(true); - when(testCaseFinished.getTestCase()).thenReturn(testCase); - when(testCase.getName()).thenReturn("Test Scenario"); - when(resultConstructor.constructTestRunResult(testCaseFinished)).thenReturn(testResult); - doThrow(originalException).when(runManager).reportTest(testResult); - - // When & Then - ReportTestResultException exception = assertThrows( - ReportTestResultException.class, - () -> listener.handleTestCaseFinished(testCaseFinished) - ); - - assertTrue(exception.getMessage().contains("Failed to report test result for: Test Scenario")); - assertNull(exception.getCause()); // ReportTestResultException doesn't chain the cause properly - } - - @Test - void shouldHandleUnknownTestWhenTestCaseIsNull() { - // Given - when(runManager.isActive()).thenReturn(true); - when(testCaseFinished.getTestCase()).thenReturn(null); - when(resultConstructor.constructTestRunResult(testCaseFinished)) - .thenThrow(new RuntimeException("Constructor error")); - - // When & Then - ReportTestResultException exception = assertThrows( - ReportTestResultException.class, - () -> listener.handleTestCaseFinished(testCaseFinished) - ); - - assertTrue(exception.getMessage().contains("Failed to report test result for: Unknown Test")); - } - - @Test - void shouldHandleUnknownTestWhenTestNameIsNull() { - // Given - when(runManager.isActive()).thenReturn(true); - when(testCaseFinished.getTestCase()).thenReturn(testCase); - when(testCase.getName()).thenReturn(null); - when(resultConstructor.constructTestRunResult(testCaseFinished)) - .thenThrow(new RuntimeException("Constructor error")); - - // When & Then - ReportTestResultException exception = assertThrows( - ReportTestResultException.class, - () -> listener.handleTestCaseFinished(testCaseFinished) - ); - - // The actual implementation uses the test name or null if getName() returns null - assertTrue(exception.getMessage().contains("Failed to report test result for: null")); - } - - @Test - void shouldVerifyListenerImplementsCorrectInterfaces() { - // Then - assertTrue(listener instanceof io.cucumber.plugin.Plugin); - assertTrue(listener instanceof io.cucumber.plugin.EventListener); - } - - @Test - void shouldVerifyEventPublisherRegistration() { - // Given - listener.setEventPublisher(eventPublisher); - - // Then - verify(eventPublisher, times(3)).registerHandlerFor(any(), any()); - } -} \ No newline at end of file +//package io.testomat.cucumber.listener; +// +//import io.cucumber.plugin.event.EventPublisher; +//import io.cucumber.plugin.event.TestCase; +//import io.cucumber.plugin.event.TestCaseFinished; +//import io.cucumber.plugin.event.TestRunFinished; +//import io.cucumber.plugin.event.TestRunStarted; +//import io.testomat.core.exception.ReportTestResultException; +//import io.testomat.core.model.TestResult; +//import io.testomat.core.runmanager.GlobalRunManager; +//import io.testomat.cucumber.constructor.CucumberTestResultConstructor; +//import io.testomat.cucumber.exception.CucumberListenerException; +//import org.junit.jupiter.api.BeforeEach; +//import org.junit.jupiter.api.Test; +//import org.mockito.Mock; +//import org.mockito.MockitoAnnotations; +// +//import static org.junit.jupiter.api.Assertions.*; +//import static org.mockito.ArgumentMatchers.any; +//import static org.mockito.ArgumentMatchers.eq; +//import static org.mockito.Mockito.*; +// +//class CucumberListenerTest { +// +// @Mock +// private CucumberTestResultConstructor resultConstructor; +// +// @Mock +// private GlobalRunManager runManager; +// +// @Mock +// private EventPublisher eventPublisher; +// +// @Mock +// private TestCaseFinished testCaseFinished; +// +// @Mock +// private TestCase testCase; +// +// @Mock +// private TestResult testResult; +// +// @Mock +// private TestRunStarted testRunStarted; +// +// @Mock +// private TestRunFinished testRunFinished; +// +// private CucumberListener listener; +// +// @BeforeEach +// void setUp() { +// MockitoAnnotations.openMocks(this); +// listener = new CucumberListener(resultConstructor, runManager); +// } +// +// @Test +// void shouldCreateDefaultConstructor() { +// CucumberListener defaultListener = new CucumberListener(); +// assertNotNull(defaultListener); +// } +// +// @Test +// void shouldRegisterEventHandlers() { +// // When +// listener.setEventPublisher(eventPublisher); +// +// // Then +// verify(eventPublisher).registerHandlerFor(eq(TestRunStarted.class), any()); +// verify(eventPublisher).registerHandlerFor(eq(TestRunFinished.class), any()); +// verify(eventPublisher).registerHandlerFor(eq(TestCaseFinished.class), any()); +// } +// +// @Test +// void shouldIncrementSuiteCounterOnTestRunStarted() { +// // Given +// when(runManager.isActive()).thenReturn(true); +// listener.setEventPublisher(eventPublisher); +// +// // Verify that the handler was registered +// verify(eventPublisher).registerHandlerFor(eq(TestRunStarted.class), any()); +// +// // We can't easily test the lambda directly, but we can verify the registration +// verify(runManager, never()).incrementSuiteCounter(); +// } +// +// @Test +// void shouldDecrementSuiteCounterOnTestRunFinished() { +// // Given +// when(runManager.isActive()).thenReturn(true); +// listener.setEventPublisher(eventPublisher); +// +// // Verify that the handler was registered +// verify(eventPublisher).registerHandlerFor(eq(TestRunFinished.class), any()); +// +// // We can't easily test the lambda directly, but we can verify the registration +// verify(runManager, never()).decrementSuiteCounter(); +// } +// +// @Test +// void shouldHandleTestCaseFinishedWhenRunManagerIsActive() { +// // Given +// when(runManager.isActive()).thenReturn(true); +// when(testCaseFinished.getTestCase()).thenReturn(testCase); +// when(testCase.getName()).thenReturn("Test Scenario"); +// when(resultConstructor.constructTestRunResult(testCaseFinished)).thenReturn(testResult); +// +// // When +// listener.handleTestCaseFinished(testCaseFinished); +// +// // Then +// verify(resultConstructor).constructTestRunResult(testCaseFinished); +// verify(runManager).reportTest(testResult); +// } +// +// @Test +// void shouldNotHandleTestCaseFinishedWhenRunManagerIsInactive() { +// // Given +// when(runManager.isActive()).thenReturn(false); +// +// // When +// listener.handleTestCaseFinished(testCaseFinished); +// +// // Then +// verify(resultConstructor, never()).constructTestRunResult(any()); +// verify(runManager, never()).reportTest(any()); +// } +// +// @Test +// void shouldThrowExceptionWhenEventIsNull() { +// // Given +// when(runManager.isActive()).thenReturn(true); +// +// // When & Then +// CucumberListenerException exception = assertThrows( +// CucumberListenerException.class, +// () -> listener.handleTestCaseFinished(null) +// ); +// +// assertEquals("The listener received null event", exception.getMessage()); +// } +// +// @Test +// void shouldWrapExceptionWhenConstructorFails() { +// // Given +// RuntimeException originalException = new RuntimeException("Constructor error"); +// when(runManager.isActive()).thenReturn(true); +// when(testCaseFinished.getTestCase()).thenReturn(testCase); +// when(testCase.getName()).thenReturn("Test Scenario"); +// when(resultConstructor.constructTestRunResult(testCaseFinished)) +// .thenThrow(originalException); +// +// // When & Then +// ReportTestResultException exception = assertThrows( +// ReportTestResultException.class, +// () -> listener.handleTestCaseFinished(testCaseFinished) +// ); +// +// assertTrue(exception.getMessage().contains("Failed to report test result for: Test Scenario")); +// assertNull(exception.getCause()); // ReportTestResultException doesn't chain the cause properly +// } +// +// @Test +// void shouldWrapExceptionWhenReportTestFails() { +// // Given +// RuntimeException originalException = new RuntimeException("Report error"); +// when(runManager.isActive()).thenReturn(true); +// when(testCaseFinished.getTestCase()).thenReturn(testCase); +// when(testCase.getName()).thenReturn("Test Scenario"); +// when(resultConstructor.constructTestRunResult(testCaseFinished)).thenReturn(testResult); +// doThrow(originalException).when(runManager).reportTest(testResult); +// +// // When & Then +// ReportTestResultException exception = assertThrows( +// ReportTestResultException.class, +// () -> listener.handleTestCaseFinished(testCaseFinished) +// ); +// +// assertTrue(exception.getMessage().contains("Failed to report test result for: Test Scenario")); +// assertNull(exception.getCause()); // ReportTestResultException doesn't chain the cause properly +// } +// +// @Test +// void shouldHandleUnknownTestWhenTestCaseIsNull() { +// // Given +// when(runManager.isActive()).thenReturn(true); +// when(testCaseFinished.getTestCase()).thenReturn(null); +// when(resultConstructor.constructTestRunResult(testCaseFinished)) +// .thenThrow(new RuntimeException("Constructor error")); +// +// // When & Then +// ReportTestResultException exception = assertThrows( +// ReportTestResultException.class, +// () -> listener.handleTestCaseFinished(testCaseFinished) +// ); +// +// assertTrue(exception.getMessage().contains("Failed to report test result for: Unknown Test")); +// } +// +// @Test +// void shouldHandleUnknownTestWhenTestNameIsNull() { +// // Given +// when(runManager.isActive()).thenReturn(true); +// when(testCaseFinished.getTestCase()).thenReturn(testCase); +// when(testCase.getName()).thenReturn(null); +// when(resultConstructor.constructTestRunResult(testCaseFinished)) +// .thenThrow(new RuntimeException("Constructor error")); +// +// // When & Then +// ReportTestResultException exception = assertThrows( +// ReportTestResultException.class, +// () -> listener.handleTestCaseFinished(testCaseFinished) +// ); +// +// // The actual implementation uses the test name or null if getName() returns null +// assertTrue(exception.getMessage().contains("Failed to report test result for: null")); +// } +// +// @Test +// void shouldVerifyListenerImplementsCorrectInterfaces() { +// // Then +// assertTrue(listener instanceof io.cucumber.plugin.Plugin); +// assertTrue(listener instanceof io.cucumber.plugin.EventListener); +// } +// +// @Test +// void shouldVerifyEventPublisherRegistration() { +// // Given +// listener.setEventPublisher(eventPublisher); +// +// // Then +// verify(eventPublisher, times(3)).registerHandlerFor(any(), any()); +// } +//} \ No newline at end of file diff --git a/java-reporter-testng/src/main/java/io/testomat/testng/listener/TestNgListener.java b/java-reporter-testng/src/main/java/io/testomat/testng/listener/TestNgListener.java index 38ffb0c..40f2085 100644 --- a/java-reporter-testng/src/main/java/io/testomat/testng/listener/TestNgListener.java +++ b/java-reporter-testng/src/main/java/io/testomat/testng/listener/TestNgListener.java @@ -175,7 +175,7 @@ private void exportTestClassIfNotProcessed(Class testClass) { @Override public void afterInvocation(IInvokedMethod method, ITestResult testResult) { if (method.isTestMethod() && !defineArtifactsDisabled()) { - awsService.uploadAllArtifactsForTest(testResult.getTestName(), + awsService.uploadAllArtifactsForTest(testResult.getName(), testNgParameterExtractor.generateRid(testResult), metaDataExtractor.getTestId( method.getTestMethod().getConstructorOrMethod().getMethod()) From 20a141d07b59a758366bb6df135bb6af4f25ff9d Mon Sep 17 00:00:00 2001 From: Yevhenii Vlasenko Date: Sun, 28 Sep 2025 22:13:53 +0300 Subject: [PATCH 31/44] Refactored AwsService --- .../core/{ => artifact}/ArtifactLinkData.java | 2 +- .../ArtifactLinkDataStorage.java | 2 +- .../core/artifact/LinkUploadBodyBuilder.java | 2 - .../{ => artifact}/ReportedTestStorage.java | 2 +- .../core/artifact/client/AwsService.java | 189 ++++++++++++------ .../{ => manager}/ArtifactManager.java | 3 +- .../core/client/TestomatioClient.java | 4 +- .../request/NativeRequestBodyBuilder.java | 2 +- .../io/testomat/core/facade/Testomatio.java | 2 +- .../core/runmanager/GlobalRunManager.java | 4 +- 10 files changed, 143 insertions(+), 69 deletions(-) rename java-reporter-core/src/main/java/io/testomat/core/{ => artifact}/ArtifactLinkData.java (97%) rename java-reporter-core/src/main/java/io/testomat/core/{ => artifact}/ArtifactLinkDataStorage.java (92%) rename java-reporter-core/src/main/java/io/testomat/core/{ => artifact}/ReportedTestStorage.java (98%) rename java-reporter-core/src/main/java/io/testomat/core/artifact/{ => manager}/ArtifactManager.java (92%) diff --git a/java-reporter-core/src/main/java/io/testomat/core/ArtifactLinkData.java b/java-reporter-core/src/main/java/io/testomat/core/artifact/ArtifactLinkData.java similarity index 97% rename from java-reporter-core/src/main/java/io/testomat/core/ArtifactLinkData.java rename to java-reporter-core/src/main/java/io/testomat/core/artifact/ArtifactLinkData.java index 8649c0b..4e4c5d4 100644 --- a/java-reporter-core/src/main/java/io/testomat/core/ArtifactLinkData.java +++ b/java-reporter-core/src/main/java/io/testomat/core/artifact/ArtifactLinkData.java @@ -1,4 +1,4 @@ -package io.testomat.core; +package io.testomat.core.artifact; import java.util.List; diff --git a/java-reporter-core/src/main/java/io/testomat/core/ArtifactLinkDataStorage.java b/java-reporter-core/src/main/java/io/testomat/core/artifact/ArtifactLinkDataStorage.java similarity index 92% rename from java-reporter-core/src/main/java/io/testomat/core/ArtifactLinkDataStorage.java rename to java-reporter-core/src/main/java/io/testomat/core/artifact/ArtifactLinkDataStorage.java index 9a678ea..805bec7 100644 --- a/java-reporter-core/src/main/java/io/testomat/core/ArtifactLinkDataStorage.java +++ b/java-reporter-core/src/main/java/io/testomat/core/artifact/ArtifactLinkDataStorage.java @@ -1,4 +1,4 @@ -package io.testomat.core; +package io.testomat.core.artifact; import java.util.List; import java.util.concurrent.CopyOnWriteArrayList; diff --git a/java-reporter-core/src/main/java/io/testomat/core/artifact/LinkUploadBodyBuilder.java b/java-reporter-core/src/main/java/io/testomat/core/artifact/LinkUploadBodyBuilder.java index 7ef37fe..5c85e7e 100644 --- a/java-reporter-core/src/main/java/io/testomat/core/artifact/LinkUploadBodyBuilder.java +++ b/java-reporter-core/src/main/java/io/testomat/core/artifact/LinkUploadBodyBuilder.java @@ -9,9 +9,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.node.ArrayNode; import com.fasterxml.jackson.databind.node.ObjectNode; -import io.testomat.core.ArtifactLinkData; import java.util.List; -import java.util.Map; import org.slf4j.Logger; import org.slf4j.LoggerFactory; diff --git a/java-reporter-core/src/main/java/io/testomat/core/ReportedTestStorage.java b/java-reporter-core/src/main/java/io/testomat/core/artifact/ReportedTestStorage.java similarity index 98% rename from java-reporter-core/src/main/java/io/testomat/core/ReportedTestStorage.java rename to java-reporter-core/src/main/java/io/testomat/core/artifact/ReportedTestStorage.java index 006e60b..2668ac7 100644 --- a/java-reporter-core/src/main/java/io/testomat/core/ReportedTestStorage.java +++ b/java-reporter-core/src/main/java/io/testomat/core/artifact/ReportedTestStorage.java @@ -1,4 +1,4 @@ -package io.testomat.core; +package io.testomat.core.artifact; import java.util.List; import java.util.Map; diff --git a/java-reporter-core/src/main/java/io/testomat/core/artifact/client/AwsService.java b/java-reporter-core/src/main/java/io/testomat/core/artifact/client/AwsService.java index c51d216..7a7d0aa 100644 --- a/java-reporter-core/src/main/java/io/testomat/core/artifact/client/AwsService.java +++ b/java-reporter-core/src/main/java/io/testomat/core/artifact/client/AwsService.java @@ -1,7 +1,7 @@ package io.testomat.core.artifact.client; -import io.testomat.core.ArtifactLinkData; -import io.testomat.core.ArtifactLinkDataStorage; +import io.testomat.core.artifact.ArtifactLinkData; +import io.testomat.core.artifact.ArtifactLinkDataStorage; import io.testomat.core.artifact.TempArtifactDirectoriesStorage; import io.testomat.core.artifact.credential.CredentialsManager; import io.testomat.core.artifact.credential.S3Credentials; @@ -15,6 +15,7 @@ import java.util.ArrayList; import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.concurrent.ConcurrentHashMap; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -30,6 +31,12 @@ public class AwsService { private static final Logger log = LoggerFactory.getLogger(AwsService.class); private static final Map bucketAclSupport = new ConcurrentHashMap<>(); + private static final String ACL_PRIVATE = "private"; + private static final String ACL_PUBLIC_READ = "public-read"; + private static final String ERROR_CODE_ACL_NOT_SUPPORTED = "AccessControlListNotSupported"; + private static final String ERROR_CODE_BUCKET_LOCKED = "BucketMustHaveLockedConfiguration"; + private static final String ERROR_MESSAGE_NO_ACL = "does not allow ACLs"; + private final ArtifactKeyGenerator keyGenerator; private final ArtifactUrlGenerator urlGenerator; private final AwsClient awsClient; @@ -54,102 +61,170 @@ public AwsService(ArtifactKeyGenerator keyGenerator, AwsClient awsClient, * @param testName the name of the test * @param rid the request identifier * @param testId the unique test identifier + * @throws IllegalArgumentException if any parameter is null */ public void uploadAllArtifactsForTest(String testName, String rid, String testId) { - if (TempArtifactDirectoriesStorage.DIRECTORIES.get().isEmpty()) { + validateParameters(testName, rid, testId); + + List artifactDirectories = TempArtifactDirectoriesStorage.DIRECTORIES.get(); + if (artifactDirectories.isEmpty()) { log.debug("Artifact list is empty for test: {}", testName); return; } S3Credentials credentials = CredentialsManager.getCredentials(); - List uploadedArtifactsLinks = new ArrayList<>(); + List uploadedArtifactsLinks = processArtifacts(artifactDirectories, testName, rid, credentials); + + storeArtifactLinkData(testName, rid, testId, uploadedArtifactsLinks); + } + + private void validateParameters(String testName, String rid, String testId) { + Objects.requireNonNull(testName, "Test name cannot be null"); + Objects.requireNonNull(rid, "Request ID cannot be null"); + Objects.requireNonNull(testId, "Test ID cannot be null"); + } + + private List processArtifacts(List artifactDirectories, String testName, String rid, S3Credentials credentials) { + List uploadedLinks = new ArrayList<>(); - for (String dir : TempArtifactDirectoriesStorage.DIRECTORIES.get()) { + for (String dir : artifactDirectories) { String key = keyGenerator.generateKey(dir, rid, testName); uploadArtifact(dir, key, credentials); - uploadedArtifactsLinks.add(urlGenerator.generateUrl(credentials.getBucket(), key)); + uploadedLinks.add(urlGenerator.generateUrl(credentials.getBucket(), key)); } - ArtifactLinkDataStorage.ARTEFACT_LINK_DATA_STORAGE.add(new ArtifactLinkData(testName, rid, testId, uploadedArtifactsLinks)); + return uploadedLinks; + } + + private void storeArtifactLinkData(String testName, String rid, String testId, List uploadedLinks) { + ArtifactLinkData linkData = new ArtifactLinkData(testName, rid, testId, uploadedLinks); + ArtifactLinkDataStorage.ARTEFACT_LINK_DATA_STORAGE.add(linkData); } private void uploadArtifact(String dir, String key, S3Credentials credentials) { - byte[] content; + Objects.requireNonNull(dir, "Directory path cannot be null"); + Objects.requireNonNull(key, "S3 key cannot be null"); + Objects.requireNonNull(credentials, "S3 credentials cannot be null"); + Path path = Paths.get(dir); + byte[] content = readFileContent(path); + + log.debug("Uploading to S3: bucket={}, key={}, size={} bytes", + credentials.getBucket(), key, content.length); + + uploadWithAclStrategy(path, key, credentials, content); + } + + private byte[] readFileContent(Path path) { try { - content = Files.readAllBytes(path); + byte[] content = Files.readAllBytes(path); log.debug("Successfully read {} bytes from file: {}", content.length, path); + return content; } catch (IOException e) { log.error("Failed to read bytes from path: {}", path, e); - throw new ArtifactManagementException("Failed to read bytes from path: " + path); + throw new ArtifactManagementException("Failed to read bytes from path: " + path, e); } + } - log.debug("Uploading to S3: bucket={}, key={}, size={} bytes", - credentials.getBucket(), key, content.length); - + private void uploadWithAclStrategy(Path path, String key, S3Credentials credentials, byte[] content) { String bucketName = credentials.getBucket(); Boolean supportsAcl = bucketAclSupport.get(bucketName); if (supportsAcl == null) { - boolean uploadSuccessful = tryUploadWithAcl(path, key, credentials, content); - if (!uploadSuccessful) { - bucketAclSupport.put(bucketName, false); - tryUploadWithoutAcl(path, key, credentials, content); - } else { - bucketAclSupport.put(bucketName, true); - } + detectAndUpload(path, key, credentials, content, bucketName); } else if (supportsAcl) { - tryUploadWithAcl(path, key, credentials, content); + performUploadWithAcl(path, key, credentials, content); + } else { + performUploadWithoutAcl(path, key, credentials, content); + } + } + + private void detectAndUpload(Path path, String key, S3Credentials credentials, byte[] content, String bucketName) { + boolean uploadSuccessful = tryUploadWithAcl(path, key, credentials, content); + if (uploadSuccessful) { + bucketAclSupport.put(bucketName, true); } else { - tryUploadWithoutAcl(path, key, credentials, content); + bucketAclSupport.put(bucketName, false); + performUploadWithoutAcl(path, key, credentials, content); } } private boolean tryUploadWithAcl(Path path, String key, S3Credentials credentials, byte[] content) { try { - PutObjectRequest.Builder builder = PutObjectRequest.builder() - .bucket(credentials.getBucket()) - .key(key); - - if (credentials.isPresign()) { - builder.acl("private"); - } else { - builder.acl("public-read"); - } - PutObjectRequest request = builder.build(); - - awsClient.getS3Client().putObject(request, RequestBody.fromBytes(content)); + PutObjectRequest request = buildUploadRequestWithAcl(credentials, key); + performS3Upload(request, content); log.debug("S3 upload completed successfully with ACL for file: {}", path); return true; } catch (S3Exception e) { - if (isAclNotSupportedError(e)) { - log.info("Bucket '{}' does not support ACLs, will retry without ACL", credentials.getBucket()); - return false; - } else { - log.error("S3 upload failed for file: {} to bucket: {}, key: {} - {} (Status: {})", - path, credentials.getBucket(), key, e.awsErrorDetails().errorMessage(), e.statusCode()); - throw new ArtifactManagementException("S3 upload failed: " + e.awsErrorDetails().errorMessage(), e); - } + return handleS3Exception(e, path, credentials, key); } catch (Exception e) { - log.error("S3 upload failed for file: {} to bucket: {}, key: {}", path, credentials.getBucket(), key, e); - throw new ArtifactManagementException("S3 upload failed: " + e.getMessage(), e); + handleGenericException(e, path, credentials, key); + return false; } } - private void tryUploadWithoutAcl(Path path, String key, S3Credentials credentials, byte[] content) { + private void performUploadWithAcl(Path path, String key, S3Credentials credentials, byte[] content) { try { - PutObjectRequest request = PutObjectRequest.builder() - .bucket(credentials.getBucket()) - .key(key) - .build(); + PutObjectRequest request = buildUploadRequestWithAcl(credentials, key); + performS3Upload(request, content); + log.debug("S3 upload completed successfully with ACL for file: {}", path); + } catch (Exception e) { + handleUploadException(e, path, credentials, key); + } + } + + private PutObjectRequest buildUploadRequestWithAcl(S3Credentials credentials, String key) { + String acl = credentials.isPresign() ? ACL_PRIVATE : ACL_PUBLIC_READ; + return PutObjectRequest.builder() + .bucket(credentials.getBucket()) + .key(key) + .acl(acl) + .build(); + } - awsClient.getS3Client().putObject(request, RequestBody.fromBytes(content)); + private void performUploadWithoutAcl(Path path, String key, S3Credentials credentials, byte[] content) { + try { + PutObjectRequest request = buildUploadRequestWithoutAcl(credentials, key); + performS3Upload(request, content); log.info("S3 upload completed successfully for file: {}", path); - } catch (S3Exception e) { - log.error("S3 upload failed for file: {} to bucket: {}, key: {} - {} (Status: {})", - path, credentials.getBucket(), key, e.awsErrorDetails().errorMessage(), e.statusCode()); - throw new ArtifactManagementException("S3 upload failed: " + e.awsErrorDetails().errorMessage(), e); } catch (Exception e) { + handleUploadException(e, path, credentials, key); + } + } + + private PutObjectRequest buildUploadRequestWithoutAcl(S3Credentials credentials, String key) { + return PutObjectRequest.builder() + .bucket(credentials.getBucket()) + .key(key) + .build(); + } + + private void performS3Upload(PutObjectRequest request, byte[] content) { + awsClient.getS3Client().putObject(request, RequestBody.fromBytes(content)); + } + + private boolean handleS3Exception(S3Exception e, Path path, S3Credentials credentials, String key) { + if (isAclNotSupportedError(e)) { + log.info("Bucket '{}' does not support ACLs, will retry without ACL", credentials.getBucket()); + return false; + } else { + handleUploadException(e, path, credentials, key); + return false; + } + } + + private void handleGenericException(Exception e, Path path, S3Credentials credentials, String key) { + log.error("S3 upload failed for file: {} to bucket: {}, key: {}", path, credentials.getBucket(), key, e); + throw new ArtifactManagementException("S3 upload failed: " + e.getMessage(), e); + } + + private void handleUploadException(Exception e, Path path, S3Credentials credentials, String key) { + if (e instanceof S3Exception) { + S3Exception s3e = (S3Exception) e; + log.error("S3 upload failed for file: {} to bucket: {}, key: {} - {} (Status: {})", + path, credentials.getBucket(), key, s3e.awsErrorDetails().errorMessage(), s3e.statusCode()); + throw new ArtifactManagementException("S3 upload failed: " + s3e.awsErrorDetails().errorMessage(), e); + } else { log.error("S3 upload failed for file: {} to bucket: {}, key: {}", path, credentials.getBucket(), key, e); throw new ArtifactManagementException("S3 upload failed: " + e.getMessage(), e); } @@ -157,9 +232,9 @@ private void tryUploadWithoutAcl(Path path, String key, S3Credentials credential private boolean isAclNotSupportedError(S3Exception e) { return (e.statusCode() == 400 && - ("AccessControlListNotSupported".equals(e.awsErrorDetails().errorCode()) || - "BucketMustHaveLockedConfiguration".equals(e.awsErrorDetails().errorCode()) || + (ERROR_CODE_ACL_NOT_SUPPORTED.equals(e.awsErrorDetails().errorCode()) || + ERROR_CODE_BUCKET_LOCKED.equals(e.awsErrorDetails().errorCode()) || (e.awsErrorDetails().errorMessage() != null && - e.awsErrorDetails().errorMessage().contains("does not allow ACLs")))); + e.awsErrorDetails().errorMessage().contains(ERROR_MESSAGE_NO_ACL)))); } } diff --git a/java-reporter-core/src/main/java/io/testomat/core/artifact/ArtifactManager.java b/java-reporter-core/src/main/java/io/testomat/core/artifact/manager/ArtifactManager.java similarity index 92% rename from java-reporter-core/src/main/java/io/testomat/core/artifact/ArtifactManager.java rename to java-reporter-core/src/main/java/io/testomat/core/artifact/manager/ArtifactManager.java index e3f57a6..19929b1 100644 --- a/java-reporter-core/src/main/java/io/testomat/core/artifact/ArtifactManager.java +++ b/java-reporter-core/src/main/java/io/testomat/core/artifact/manager/ArtifactManager.java @@ -1,10 +1,11 @@ -package io.testomat.core.artifact; +package io.testomat.core.artifact.manager; /** * Manager for handling test artifacts with path validation and storage. * Provides secure file path validation and artifact registration for test runs. */ +import io.testomat.core.artifact.TempArtifactDirectoriesStorage; import io.testomat.core.exception.ArtifactManagementException; import java.nio.file.Files; import java.nio.file.InvalidPathException; diff --git a/java-reporter-core/src/main/java/io/testomat/core/client/TestomatioClient.java b/java-reporter-core/src/main/java/io/testomat/core/client/TestomatioClient.java index 0c039f9..e05b3bb 100644 --- a/java-reporter-core/src/main/java/io/testomat/core/client/TestomatioClient.java +++ b/java-reporter-core/src/main/java/io/testomat/core/client/TestomatioClient.java @@ -3,8 +3,8 @@ import static io.testomat.core.constants.CommonConstants.REPORTER_VERSION; import static io.testomat.core.constants.CommonConstants.RESPONSE_UID_KEY; -import io.testomat.core.ArtifactLinkDataStorage; -import io.testomat.core.ReportedTestStorage; +import io.testomat.core.artifact.ArtifactLinkDataStorage; +import io.testomat.core.artifact.ReportedTestStorage; import io.testomat.core.artifact.LinkUploadBodyBuilder; import io.testomat.core.artifact.credential.CredentialsManager; import io.testomat.core.artifact.credential.CredentialsValidationService; diff --git a/java-reporter-core/src/main/java/io/testomat/core/client/request/NativeRequestBodyBuilder.java b/java-reporter-core/src/main/java/io/testomat/core/client/request/NativeRequestBodyBuilder.java index c9ee89d..5d07b53 100644 --- a/java-reporter-core/src/main/java/io/testomat/core/client/request/NativeRequestBodyBuilder.java +++ b/java-reporter-core/src/main/java/io/testomat/core/client/request/NativeRequestBodyBuilder.java @@ -11,7 +11,7 @@ import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; -import io.testomat.core.ReportedTestStorage; +import io.testomat.core.artifact.ReportedTestStorage; import io.testomat.core.constants.ApiRequestFields; import io.testomat.core.exception.FailedToCreateRunBodyException; import io.testomat.core.model.TestResult; diff --git a/java-reporter-core/src/main/java/io/testomat/core/facade/Testomatio.java b/java-reporter-core/src/main/java/io/testomat/core/facade/Testomatio.java index 2afcf73..6f84a18 100644 --- a/java-reporter-core/src/main/java/io/testomat/core/facade/Testomatio.java +++ b/java-reporter-core/src/main/java/io/testomat/core/facade/Testomatio.java @@ -1,6 +1,6 @@ package io.testomat.core.facade; -import io.testomat.core.artifact.ArtifactManager; +import io.testomat.core.artifact.manager.ArtifactManager; /** * Main public API facade for Testomat.io integration. diff --git a/java-reporter-core/src/main/java/io/testomat/core/runmanager/GlobalRunManager.java b/java-reporter-core/src/main/java/io/testomat/core/runmanager/GlobalRunManager.java index fc48431..525f420 100644 --- a/java-reporter-core/src/main/java/io/testomat/core/runmanager/GlobalRunManager.java +++ b/java-reporter-core/src/main/java/io/testomat/core/runmanager/GlobalRunManager.java @@ -4,8 +4,8 @@ import static io.testomat.core.constants.PropertyNameConstants.DISABLE_REPORTING_PROPERTY_NAME; import static io.testomat.core.constants.PropertyNameConstants.RUN_TITLE_PROPERTY_NAME; -import io.testomat.core.ArtifactLinkDataStorage; -import io.testomat.core.ReportedTestStorage; +import io.testomat.core.artifact.ArtifactLinkDataStorage; +import io.testomat.core.artifact.ReportedTestStorage; import io.testomat.core.artifact.util.ArtifactKeyGenerator; import io.testomat.core.batch.BatchResultManager; import io.testomat.core.client.ApiInterface; From 5f66cbc313abbb8d9c28822304cae8f4584a9a76 Mon Sep 17 00:00:00 2001 From: Yevhenii Vlasenko Date: Mon, 29 Sep 2025 12:08:19 +0300 Subject: [PATCH 32/44] Fixed tests in Cucumber module. Added new tests --- .../cucumber/extractor/TestDataExtractor.java | 6 +- .../cucumber/listener/CucumberListener.java | 3 +- .../CucumberTestResultConstructorTest.java | 6 +- .../extractor/TestDataExtractorTest.java | 78 +++- .../listener/CucumberListenerTest.java | 432 +++++++++--------- 5 files changed, 309 insertions(+), 216 deletions(-) diff --git a/java-reporter-cucumber/src/main/java/io/testomat/cucumber/extractor/TestDataExtractor.java b/java-reporter-cucumber/src/main/java/io/testomat/cucumber/extractor/TestDataExtractor.java index fcb4330..686be36 100644 --- a/java-reporter-cucumber/src/main/java/io/testomat/cucumber/extractor/TestDataExtractor.java +++ b/java-reporter-cucumber/src/main/java/io/testomat/cucumber/extractor/TestDataExtractor.java @@ -114,7 +114,11 @@ public String extractTitle(TestCaseFinished event) { * @return feature file name, null if extraction fails */ public String extractFileName(TestCaseFinished event) { - return event.getTestCase().getUri().toString(); + try { + return event.getTestCase().getUri().toString(); + } catch (Exception e) { + return null; + } } /** diff --git a/java-reporter-cucumber/src/main/java/io/testomat/cucumber/listener/CucumberListener.java b/java-reporter-cucumber/src/main/java/io/testomat/cucumber/listener/CucumberListener.java index 59643f1..d9e275d 100644 --- a/java-reporter-cucumber/src/main/java/io/testomat/cucumber/listener/CucumberListener.java +++ b/java-reporter-cucumber/src/main/java/io/testomat/cucumber/listener/CucumberListener.java @@ -77,11 +77,12 @@ void handleTestCaseFinished(TestCaseFinished event) { try { TestResult result = resultConstructor.constructTestRunResult(event); runManager.reportTest(result); - afterEach(event); } catch (Exception e) { String testName = event.getTestCase() != null ? event.getTestCase().getName() : "Unknown Test"; throw new ReportTestResultException("Failed to report test result for: " + testName, e); + } finally { + afterEach(event); } } diff --git a/java-reporter-cucumber/src/test/java/io/testomat/cucumber/constructor/CucumberTestResultConstructorTest.java b/java-reporter-cucumber/src/test/java/io/testomat/cucumber/constructor/CucumberTestResultConstructorTest.java index 0841d7c..923028e 100644 --- a/java-reporter-cucumber/src/test/java/io/testomat/cucumber/constructor/CucumberTestResultConstructorTest.java +++ b/java-reporter-cucumber/src/test/java/io/testomat/cucumber/constructor/CucumberTestResultConstructorTest.java @@ -64,7 +64,7 @@ void shouldConstructTestRunResultWithAllFields() { when(testDataExtractor.getNormalizedStatus(testCaseFinished)).thenReturn("PASSED"); when(testDataExtractor.createExample(testCaseFinished)).thenReturn(example); when(testDataExtractor.extractTestId(testCaseFinished)).thenReturn("@T12345678"); - when(testDataExtractor.extractFileName(testCaseFinished)).thenReturn("TestFeature.feature"); + when(testDataExtractor.extractFileName(testCaseFinished)).thenReturn("file:///test/path/TestFeature.feature"); when(testDataExtractor.extractTitle(testCaseFinished)).thenReturn("Test Title"); // When @@ -76,7 +76,7 @@ void shouldConstructTestRunResultWithAllFields() { assertEquals("file:///test/path/TestFeature.feature", result.getSuiteTitle()); assertEquals(example, result.getExample()); assertEquals("@T12345678", result.getTestId()); - assertEquals("TestFeature.feature", result.getFile()); + assertEquals("file:///test/path/TestFeature.feature", result.getFile()); assertEquals("Test Title", result.getTitle()); assertEquals(testCaseId.toString(), result.getRid()); assertEquals("Test error", result.getMessage()); @@ -131,7 +131,7 @@ void shouldVerifyAllExtractorMethodsAreCalled() { when(testDataExtractor.getNormalizedStatus(any())).thenReturn("PASSED"); when(testDataExtractor.createExample(any())).thenReturn(new HashMap<>()); when(testDataExtractor.extractTestId(any())).thenReturn(null); - when(testDataExtractor.extractFileName(any())).thenReturn("test.feature"); + when(testDataExtractor.extractFileName(any())).thenReturn("file:///test.feature"); when(testDataExtractor.extractTitle(any())).thenReturn("Test"); // When diff --git a/java-reporter-cucumber/src/test/java/io/testomat/cucumber/extractor/TestDataExtractorTest.java b/java-reporter-cucumber/src/test/java/io/testomat/cucumber/extractor/TestDataExtractorTest.java index 2046f86..a08937f 100644 --- a/java-reporter-cucumber/src/test/java/io/testomat/cucumber/extractor/TestDataExtractorTest.java +++ b/java-reporter-cucumber/src/test/java/io/testomat/cucumber/extractor/TestDataExtractorTest.java @@ -244,7 +244,7 @@ void shouldExtractFileNameFromUri() { String fileName = extractor.extractFileName(testCaseFinished); // Then - assertEquals("TestFeature.feature", fileName); + assertEquals("file:///path/to/test/TestFeature.feature", fileName); } @Test @@ -258,7 +258,7 @@ void shouldExtractFileNameFromWindowsPath() { String fileName = extractor.extractFileName(testCaseFinished); // Then - assertEquals("TestFeature.feature", fileName); + assertEquals("file:///C:/path/to/test/TestFeature.feature", fileName); } @Test @@ -272,7 +272,7 @@ void shouldHandleColonInPath() { String fileName = extractor.extractFileName(testCaseFinished); // Then - assertEquals("TestFeature.feature", fileName); + assertEquals("file://C:/Users/test/features/TestFeature.feature", fileName); } @Test @@ -432,4 +432,76 @@ void shouldReturnUnknownTestWhenTestCaseThrowsException() { // Then assertEquals("Unknown test", title); } + + @Test + void shouldExtractFileNameReturnsUriToString() { + // Given + URI testUri = URI.create("classpath:features/TestFeature.feature"); + when(testCaseFinished.getTestCase()).thenReturn(testCase); + when(testCase.getUri()).thenReturn(testUri); + + // When + String fileName = extractor.extractFileName(testCaseFinished); + + // Then + assertEquals("classpath:features/TestFeature.feature", fileName); + } + + @Test + void shouldHandleHttpUriInExtractFileName() { + // Given + URI testUri = URI.create("http://example.com/test.feature"); + when(testCaseFinished.getTestCase()).thenReturn(testCase); + when(testCase.getUri()).thenReturn(testUri); + + // When + String fileName = extractor.extractFileName(testCaseFinished); + + // Then + assertEquals("http://example.com/test.feature", fileName); + } + + @Test + void shouldHandleNullTestCaseInExtractFileName() { + // Given + when(testCaseFinished.getTestCase()).thenReturn(null); + + // When + String fileName = extractor.extractFileName(testCaseFinished); + + // Then + assertNull(fileName); + } + + @Test + void shouldParseMultipleTypesFromSingleStep() { + // Given + when(testCaseFinished.getTestCase()).thenReturn(testCase); + when(testCase.getTestSteps()).thenReturn(Collections.singletonList(pickleStepTestStep)); + when(pickleStepTestStep.getStepText()).thenReturn("User enters \"test@example.com\" and password \"secret123\" and age 25 and amount 99.99"); + + // When + Map example = extractor.createExample(testCaseFinished); + + // Then + assertEquals("test@example.com", example.get("step_value_0")); + assertEquals("secret123", example.get("step_value_1")); + assertEquals(25L, example.get("step_value_2")); + assertEquals(99.99, example.get("step_value_3")); + } + + @Test + void shouldCreateExampleWithBooleanValues() { + // Given + when(testCaseFinished.getTestCase()).thenReturn(testCase); + when(testCase.getTestSteps()).thenReturn(Collections.singletonList(pickleStepTestStep)); + when(pickleStepTestStep.getStepText()).thenReturn("Set active to 'true' and visible to 'false'"); + + // When + Map example = extractor.createExample(testCaseFinished); + + // Then + assertEquals("true", example.get("step_value_0")); + assertEquals("false", example.get("step_value_1")); + } } \ No newline at end of file diff --git a/java-reporter-cucumber/src/test/java/io/testomat/cucumber/listener/CucumberListenerTest.java b/java-reporter-cucumber/src/test/java/io/testomat/cucumber/listener/CucumberListenerTest.java index 2d6be00..a0e11b7 100644 --- a/java-reporter-cucumber/src/test/java/io/testomat/cucumber/listener/CucumberListenerTest.java +++ b/java-reporter-cucumber/src/test/java/io/testomat/cucumber/listener/CucumberListenerTest.java @@ -1,75 +1,83 @@ -//package io.testomat.cucumber.listener; -// -//import io.cucumber.plugin.event.EventPublisher; -//import io.cucumber.plugin.event.TestCase; -//import io.cucumber.plugin.event.TestCaseFinished; -//import io.cucumber.plugin.event.TestRunFinished; -//import io.cucumber.plugin.event.TestRunStarted; -//import io.testomat.core.exception.ReportTestResultException; -//import io.testomat.core.model.TestResult; -//import io.testomat.core.runmanager.GlobalRunManager; -//import io.testomat.cucumber.constructor.CucumberTestResultConstructor; -//import io.testomat.cucumber.exception.CucumberListenerException; -//import org.junit.jupiter.api.BeforeEach; -//import org.junit.jupiter.api.Test; -//import org.mockito.Mock; -//import org.mockito.MockitoAnnotations; -// -//import static org.junit.jupiter.api.Assertions.*; -//import static org.mockito.ArgumentMatchers.any; -//import static org.mockito.ArgumentMatchers.eq; -//import static org.mockito.Mockito.*; -// -//class CucumberListenerTest { -// -// @Mock -// private CucumberTestResultConstructor resultConstructor; -// -// @Mock -// private GlobalRunManager runManager; -// -// @Mock -// private EventPublisher eventPublisher; -// -// @Mock -// private TestCaseFinished testCaseFinished; -// -// @Mock -// private TestCase testCase; -// -// @Mock -// private TestResult testResult; -// -// @Mock -// private TestRunStarted testRunStarted; -// -// @Mock -// private TestRunFinished testRunFinished; -// -// private CucumberListener listener; -// -// @BeforeEach -// void setUp() { -// MockitoAnnotations.openMocks(this); -// listener = new CucumberListener(resultConstructor, runManager); -// } -// -// @Test -// void shouldCreateDefaultConstructor() { -// CucumberListener defaultListener = new CucumberListener(); -// assertNotNull(defaultListener); -// } -// -// @Test -// void shouldRegisterEventHandlers() { -// // When -// listener.setEventPublisher(eventPublisher); -// -// // Then -// verify(eventPublisher).registerHandlerFor(eq(TestRunStarted.class), any()); -// verify(eventPublisher).registerHandlerFor(eq(TestRunFinished.class), any()); -// verify(eventPublisher).registerHandlerFor(eq(TestCaseFinished.class), any()); -// } +package io.testomat.cucumber.listener; + +import io.cucumber.plugin.event.EventPublisher; +import io.cucumber.plugin.event.TestCase; +import io.cucumber.plugin.event.TestCaseFinished; +import io.cucumber.plugin.event.TestRunFinished; +import io.cucumber.plugin.event.TestRunStarted; +import io.testomat.core.artifact.client.AwsService; +import io.testomat.core.exception.ReportTestResultException; +import io.testomat.core.model.TestResult; +import io.testomat.core.runmanager.GlobalRunManager; +import io.testomat.cucumber.constructor.CucumberTestResultConstructor; +import io.testomat.cucumber.exception.CucumberListenerException; +import io.testomat.cucumber.extractor.TestDataExtractor; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; + +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.*; + +class CucumberListenerTest { + @Mock + private CucumberTestResultConstructor resultConstructor; + + @Mock + private GlobalRunManager runManager; + + @Mock + private AwsService awsService; + + @Mock + private TestDataExtractor dataExtractor; + + @Mock + private EventPublisher eventPublisher; + + @Mock + private TestCaseFinished testCaseFinished; + + @Mock + private TestCase testCase; + + @Mock + private TestResult testResult; + + @Mock + private TestRunStarted testRunStarted; + + @Mock + private TestRunFinished testRunFinished; + + private CucumberListener listener; + + @BeforeEach + void setUp() { + MockitoAnnotations.openMocks(this); + listener = new CucumberListener(resultConstructor, runManager, awsService, dataExtractor); + } + @Test + void shouldCreateDefaultConstructor() { + CucumberListener defaultListener = new CucumberListener(); + assertNotNull(defaultListener); + } + + @Test + void shouldRegisterEventHandlers() { + // When + listener.setEventPublisher(eventPublisher); + + // Then + verify(eventPublisher).registerHandlerFor(eq(TestRunStarted.class), any()); + verify(eventPublisher).registerHandlerFor(eq(TestRunFinished.class), any()); + verify(eventPublisher).registerHandlerFor(eq(TestCaseFinished.class), any()); + } // // @Test // void shouldIncrementSuiteCounterOnTestRunStarted() { @@ -96,139 +104,147 @@ // // We can't easily test the lambda directly, but we can verify the registration // verify(runManager, never()).decrementSuiteCounter(); // } -// -// @Test -// void shouldHandleTestCaseFinishedWhenRunManagerIsActive() { -// // Given -// when(runManager.isActive()).thenReturn(true); -// when(testCaseFinished.getTestCase()).thenReturn(testCase); -// when(testCase.getName()).thenReturn("Test Scenario"); -// when(resultConstructor.constructTestRunResult(testCaseFinished)).thenReturn(testResult); -// -// // When -// listener.handleTestCaseFinished(testCaseFinished); -// -// // Then -// verify(resultConstructor).constructTestRunResult(testCaseFinished); -// verify(runManager).reportTest(testResult); -// } -// -// @Test -// void shouldNotHandleTestCaseFinishedWhenRunManagerIsInactive() { -// // Given -// when(runManager.isActive()).thenReturn(false); -// -// // When -// listener.handleTestCaseFinished(testCaseFinished); -// -// // Then -// verify(resultConstructor, never()).constructTestRunResult(any()); -// verify(runManager, never()).reportTest(any()); -// } -// -// @Test -// void shouldThrowExceptionWhenEventIsNull() { -// // Given -// when(runManager.isActive()).thenReturn(true); -// -// // When & Then -// CucumberListenerException exception = assertThrows( -// CucumberListenerException.class, -// () -> listener.handleTestCaseFinished(null) -// ); -// -// assertEquals("The listener received null event", exception.getMessage()); -// } -// -// @Test -// void shouldWrapExceptionWhenConstructorFails() { -// // Given -// RuntimeException originalException = new RuntimeException("Constructor error"); -// when(runManager.isActive()).thenReturn(true); -// when(testCaseFinished.getTestCase()).thenReturn(testCase); -// when(testCase.getName()).thenReturn("Test Scenario"); -// when(resultConstructor.constructTestRunResult(testCaseFinished)) -// .thenThrow(originalException); -// -// // When & Then -// ReportTestResultException exception = assertThrows( -// ReportTestResultException.class, -// () -> listener.handleTestCaseFinished(testCaseFinished) -// ); -// -// assertTrue(exception.getMessage().contains("Failed to report test result for: Test Scenario")); -// assertNull(exception.getCause()); // ReportTestResultException doesn't chain the cause properly -// } -// -// @Test -// void shouldWrapExceptionWhenReportTestFails() { -// // Given -// RuntimeException originalException = new RuntimeException("Report error"); -// when(runManager.isActive()).thenReturn(true); -// when(testCaseFinished.getTestCase()).thenReturn(testCase); -// when(testCase.getName()).thenReturn("Test Scenario"); -// when(resultConstructor.constructTestRunResult(testCaseFinished)).thenReturn(testResult); -// doThrow(originalException).when(runManager).reportTest(testResult); -// -// // When & Then -// ReportTestResultException exception = assertThrows( -// ReportTestResultException.class, -// () -> listener.handleTestCaseFinished(testCaseFinished) -// ); -// -// assertTrue(exception.getMessage().contains("Failed to report test result for: Test Scenario")); -// assertNull(exception.getCause()); // ReportTestResultException doesn't chain the cause properly -// } -// -// @Test -// void shouldHandleUnknownTestWhenTestCaseIsNull() { -// // Given -// when(runManager.isActive()).thenReturn(true); -// when(testCaseFinished.getTestCase()).thenReturn(null); -// when(resultConstructor.constructTestRunResult(testCaseFinished)) -// .thenThrow(new RuntimeException("Constructor error")); -// -// // When & Then -// ReportTestResultException exception = assertThrows( -// ReportTestResultException.class, -// () -> listener.handleTestCaseFinished(testCaseFinished) -// ); -// -// assertTrue(exception.getMessage().contains("Failed to report test result for: Unknown Test")); -// } -// -// @Test -// void shouldHandleUnknownTestWhenTestNameIsNull() { -// // Given -// when(runManager.isActive()).thenReturn(true); -// when(testCaseFinished.getTestCase()).thenReturn(testCase); -// when(testCase.getName()).thenReturn(null); -// when(resultConstructor.constructTestRunResult(testCaseFinished)) -// .thenThrow(new RuntimeException("Constructor error")); -// -// // When & Then -// ReportTestResultException exception = assertThrows( -// ReportTestResultException.class, -// () -> listener.handleTestCaseFinished(testCaseFinished) -// ); -// -// // The actual implementation uses the test name or null if getName() returns null -// assertTrue(exception.getMessage().contains("Failed to report test result for: null")); -// } -// -// @Test -// void shouldVerifyListenerImplementsCorrectInterfaces() { -// // Then -// assertTrue(listener instanceof io.cucumber.plugin.Plugin); -// assertTrue(listener instanceof io.cucumber.plugin.EventListener); -// } -// -// @Test -// void shouldVerifyEventPublisherRegistration() { -// // Given -// listener.setEventPublisher(eventPublisher); -// -// // Then -// verify(eventPublisher, times(3)).registerHandlerFor(any(), any()); -// } -//} \ No newline at end of file + @Test + void shouldHandleTestCaseFinishedWhenRunManagerIsActive() { + // Given + when(runManager.isActive()).thenReturn(true); + UUID testCaseId = UUID.randomUUID(); + when(testCaseFinished.getTestCase()).thenReturn(testCase); + when(testCase.getName()).thenReturn("Test Scenario"); + when(testCase.getId()).thenReturn(testCaseId); + when(resultConstructor.constructTestRunResult(testCaseFinished)).thenReturn(testResult); + when(dataExtractor.extractTitle(testCaseFinished)).thenReturn("Test Title"); + when(dataExtractor.extractTestId(testCaseFinished)).thenReturn("@T12345"); + + // When + listener.handleTestCaseFinished(testCaseFinished); + + // Then + verify(resultConstructor).constructTestRunResult(testCaseFinished); + verify(runManager).reportTest(testResult); + verify(awsService).uploadAllArtifactsForTest("Test Title", testCaseId.toString(), "@T12345"); + } + @Test + void shouldWrapExceptionWhenConstructorFails() { + // Given + when(runManager.isActive()).thenReturn(true); + UUID testCaseId = UUID.randomUUID(); + RuntimeException originalException = new RuntimeException("Constructor error"); + when(testCaseFinished.getTestCase()).thenReturn(testCase); + when(testCase.getName()).thenReturn("Test Scenario"); + when(testCase.getId()).thenReturn(testCaseId); + when(dataExtractor.extractTitle(testCaseFinished)).thenReturn("Test Title"); + when(dataExtractor.extractTestId(testCaseFinished)).thenReturn("@T12345"); + when(resultConstructor.constructTestRunResult(testCaseFinished)) + .thenThrow(originalException); + + // When & Then + ReportTestResultException exception = assertThrows( + ReportTestResultException.class, + () -> listener.handleTestCaseFinished(testCaseFinished) + ); + + assertTrue(exception.getMessage().contains("Failed to report test result for: Test Scenario")); + // Verify that afterEach was still called despite the exception + verify(awsService).uploadAllArtifactsForTest("Test Title", testCaseId.toString(), "@T12345"); + } + @Test + void shouldVerifyListenerImplementsCorrectInterfaces() { + // Then + assertTrue(listener instanceof io.cucumber.plugin.Plugin); + assertTrue(listener instanceof io.cucumber.plugin.EventListener); + } + + @Test + void shouldVerifyEventPublisherRegistration() { + // Given + listener.setEventPublisher(eventPublisher); + + // Then + verify(eventPublisher, times(3)).registerHandlerFor(any(), any()); + } + + @Test + void shouldCallAfterEachWithCorrectParameters() { + // Given + when(runManager.isActive()).thenReturn(true); + UUID testCaseId = UUID.randomUUID(); + when(testCaseFinished.getTestCase()).thenReturn(testCase); + when(testCase.getName()).thenReturn("Test Scenario"); + when(testCase.getId()).thenReturn(testCaseId); + when(resultConstructor.constructTestRunResult(testCaseFinished)).thenReturn(testResult); + when(dataExtractor.extractTitle(testCaseFinished)).thenReturn("Test Title"); + when(dataExtractor.extractTestId(testCaseFinished)).thenReturn("@T12345"); + + // When + listener.handleTestCaseFinished(testCaseFinished); + + // Then + verify(dataExtractor).extractTitle(testCaseFinished); + verify(dataExtractor).extractTestId(testCaseFinished); + verify(awsService).uploadAllArtifactsForTest("Test Title", testCaseId.toString(), "@T12345"); + } + + @Test + void shouldHandleNullTestIdInAfterEach() { + // Given + when(runManager.isActive()).thenReturn(true); + UUID testCaseId = UUID.randomUUID(); + when(testCaseFinished.getTestCase()).thenReturn(testCase); + when(testCase.getName()).thenReturn("Test Scenario"); + when(testCase.getId()).thenReturn(testCaseId); + when(resultConstructor.constructTestRunResult(testCaseFinished)).thenReturn(testResult); + when(dataExtractor.extractTitle(testCaseFinished)).thenReturn("Test Title"); + when(dataExtractor.extractTestId(testCaseFinished)).thenReturn(null); + + // When + listener.handleTestCaseFinished(testCaseFinished); + + // Then + verify(awsService).uploadAllArtifactsForTest("Test Title", testCaseId.toString(), null); + } + + @Test + void shouldHandleNullTitleInAfterEach() { + // Given + when(runManager.isActive()).thenReturn(true); + UUID testCaseId = UUID.randomUUID(); + when(testCaseFinished.getTestCase()).thenReturn(testCase); + when(testCase.getName()).thenReturn("Test Scenario"); + when(testCase.getId()).thenReturn(testCaseId); + when(resultConstructor.constructTestRunResult(testCaseFinished)).thenReturn(testResult); + when(dataExtractor.extractTitle(testCaseFinished)).thenReturn(null); + when(dataExtractor.extractTestId(testCaseFinished)).thenReturn("@T12345"); + + // When + listener.handleTestCaseFinished(testCaseFinished); + + // Then + verify(awsService).uploadAllArtifactsForTest(null, testCaseId.toString(), "@T12345"); + } + + @Test + void shouldStillCallAfterEachWhenReportTestFails() { + // Given + when(runManager.isActive()).thenReturn(true); + UUID testCaseId = UUID.randomUUID(); + RuntimeException reportException = new RuntimeException("Report failed"); + when(testCaseFinished.getTestCase()).thenReturn(testCase); + when(testCase.getName()).thenReturn("Test Scenario"); + when(testCase.getId()).thenReturn(testCaseId); + when(resultConstructor.constructTestRunResult(testCaseFinished)).thenReturn(testResult); + when(dataExtractor.extractTitle(testCaseFinished)).thenReturn("Test Title"); + when(dataExtractor.extractTestId(testCaseFinished)).thenReturn("@T12345"); + doThrow(reportException).when(runManager).reportTest(testResult); + + // When & Then + ReportTestResultException exception = assertThrows( + ReportTestResultException.class, + () -> listener.handleTestCaseFinished(testCaseFinished) + ); + + // Verify that afterEach was still called before the exception was thrown + verify(awsService).uploadAllArtifactsForTest("Test Title", testCaseId.toString(), "@T12345"); + assertTrue(exception.getMessage().contains("Failed to report test result for: Test Scenario")); + } +} \ No newline at end of file From 11f9293137afc965e77efd2d334a8b8f46af7a16 Mon Sep 17 00:00:00 2001 From: Yevhenii Vlasenko Date: Mon, 29 Sep 2025 16:40:05 +0300 Subject: [PATCH 33/44] Fixed artifacts related property names --- .../core/constants/ArtifactPropertyNames.java | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/java-reporter-core/src/main/java/io/testomat/core/constants/ArtifactPropertyNames.java b/java-reporter-core/src/main/java/io/testomat/core/constants/ArtifactPropertyNames.java index 6421864..7e4e48b 100644 --- a/java-reporter-core/src/main/java/io/testomat/core/constants/ArtifactPropertyNames.java +++ b/java-reporter-core/src/main/java/io/testomat/core/constants/ArtifactPropertyNames.java @@ -1,15 +1,15 @@ package io.testomat.core.constants; public class ArtifactPropertyNames { - public static final String ARTIFACT_DISABLE_PROPERTY_NAME = "testomatio.disable.artifacts"; public static final String BUCKET_PROPERTY_NAME = "s3.bucket"; - public static final String ACCESS_KEY_PROPERTY_NAME = "s3.access.key.id"; - public static final String SECRET_ACCESS_KEY_PROPERTY_NAME = " s3.secret.access.key.id"; + public static final String ACCESS_KEY_PROPERTY_NAME = "s3.access-key-id"; + public static final String SECRET_ACCESS_KEY_PROPERTY_NAME = " s3.secret.access-key-id"; public static final String REGION_PROPERTY_NAME = "s3.region"; public static final String ENDPOINT_PROPERTY_NAME = "s3.endpoint"; - public static final String FORCE_PATH_PROPERTY_NAME = "s3.force.path.style"; - public static final String PRIVATE_ARTIFACTS_PROPERTY_NAME = "testomatio.private.artifacts"; + public static final String FORCE_PATH_PROPERTY_NAME = "s3.force-path-style"; - public static final String MAX_SIZE_ARTIFACTS_PROPERTY_NAME = "testomatio.artifact.max.size"; + public static final String PRIVATE_ARTIFACTS_PROPERTY_NAME = "testomatio.artifact.private"; + public static final String ARTIFACT_DISABLE_PROPERTY_NAME = "testomatio.artifact.disable"; + public static final String MAX_SIZE_ARTIFACTS_PROPERTY_NAME = "testomatio.artifact.max-size"; } From 64731f35cc751b997503e244eecc0ef1617bea2f Mon Sep 17 00:00:00 2001 From: Yevhenii Vlasenko Date: Mon, 29 Sep 2025 16:44:17 +0300 Subject: [PATCH 34/44] Updated README.md --- README.md | 51 ++++++++++++++++---------------- img/artifactsOnServerTurnOn.png | Bin 0 -> 95186 bytes 2 files changed, 26 insertions(+), 25 deletions(-) create mode 100644 img/artifactsOnServerTurnOn.png diff --git a/README.md b/README.md index d39de16..e15cfc3 100644 --- a/README.md +++ b/README.md @@ -288,25 +288,32 @@ Use these oneliners to **download jar and update** ids in one move ## 📎 Test Artifacts Support The Java Reporter supports attaching files (screenshots, logs, videos, etc.) to your test results and uploading them to -S3-compatible storage. +S3-compatible storage. +Artifacts handling is enabled by default, but it won't affect the run if there are no artifacts provided (see options below). +> NOTE: Current version handles artifacts synchronously. ### Configuration -Add these properties to your `testomatio.properties`: - -```properties -# S3 Configuration (can also be provided by Testomat.io server) -s3.bucket=your-bucket-name -s3.access.key.id=your-access-key -s3.secret.access.key.id=your-secret-key -s3.region=us-east-1 -s3.endpoint=https://s3.amazonaws.com -# Optional settings -s3.force.path.style=false -testomatio.private.artifacts=false -testomatio.artifact.max.size=10485760 -testomatio.disable.artifacts=false -``` +Artifact handling can be configured in **two different ways**: + +1. Make configurations on the [Testomat.io](https://app.testomat.io): + Choose your project -> click **Settings** button on the left panel -> click **Artifacts** -> Toggle "**Share + credentials**..." + artifact example + +2. Provide options as environment variables/jvm property/testomatio.properties file. + +> NOTE: Environment variables(env/jvm/testomatio.properties) take precedence over server-provided credentials. + +| Setting | Description | Default | +|-------------------------------|--------------------------------------------------|-------------| +| `testomatio.artifact.disable` | Completely disable artifact uploading | `false` | +| `testomatio.artifact.private` | Keep artifacts private (no public URLs) | `false` | +| `s3.force-path-style` | Use path-style URLs for S3-compatible storage | `false` | +| `s3.endpoint` | Custom endpoint ot be used with force-path-style | `false` | +| `s3.bucket` | Provides bucket name for configuration | | +| `s3.access-key-id` | Access key for the bucket | | +| `s3.region` | Bucket region | `us-west-1` | **Note**: S3 credentials can be configured either in properties file or provided automatically on Testomat.io UI. Environment variables take precedence over server-provided credentials. @@ -332,7 +339,8 @@ public class MyTest { } } ``` -Multiple directories can be provided ti the `Testomatio.artifact(String ...)` facade method. +Multiple directories can be provided to the `Testomatio.artifact(String ...)` facade method. +Please, make sure you provide path to artifact file including its extension. ### How It Works @@ -341,20 +349,13 @@ Multiple directories can be provided ti the `Testomatio.artifact(String ...)` fa 3. **Link Generation**: Public URLs are generated and attached to test results 4. **Thread Safety**: Multiple concurrent tests can safely attach artifacts -### Configuration Options - -| Setting | Description | Default | -|--------------------------------|-----------------------------------------------|---------| -| `testomatio.disable.artifacts` | Completely disable artifact uploading | `false` | -| `testomatio.private.artifacts` | Keep artifacts private (no public URLs) | `false` | -| `s3.force.path.style` | Use path-style URLs for S3-compatible storage | `false` | As the result you will see something like this on UI after run completed: artifact example --- -## 💡 Usage Examples +## 💡 Library Usage Examples ### Basic Usage diff --git a/img/artifactsOnServerTurnOn.png b/img/artifactsOnServerTurnOn.png new file mode 100644 index 0000000000000000000000000000000000000000..5cb67f7a8c80927821099e9da2733c533954522b GIT binary patch literal 95186 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 QSGc2YF33|gCxZX;KW+D@r2qf` literal 0 HcmV?d00001 From c7cc19f887bddb36759a14bfe32c4de9c5a778c2 Mon Sep 17 00:00:00 2001 From: Yevhenii Vlasenko Date: Mon, 29 Sep 2025 16:57:55 +0300 Subject: [PATCH 35/44] Added message on before-artifact-delay --- .../main/java/io/testomat/core/runmanager/GlobalRunManager.java | 1 + 1 file changed, 1 insertion(+) diff --git a/java-reporter-core/src/main/java/io/testomat/core/runmanager/GlobalRunManager.java b/java-reporter-core/src/main/java/io/testomat/core/runmanager/GlobalRunManager.java index 525f420..ea9b82f 100644 --- a/java-reporter-core/src/main/java/io/testomat/core/runmanager/GlobalRunManager.java +++ b/java-reporter-core/src/main/java/io/testomat/core/runmanager/GlobalRunManager.java @@ -255,6 +255,7 @@ private synchronized void finalizeRun(boolean force) { log.debug("Test run finished: {} (duration: {}s)", uid, duration); ReportedTestStorage.linkArtifactsToTests(ArtifactLinkDataStorage.ARTEFACT_LINK_DATA_STORAGE); + log.info("Syncing artifacts with Testomat.io"); Thread.sleep(15000); client.sendTestWithArtifacts(uid); log.debug("Artifacts sent successfully for run: {}", uid); From 87ea07c4a20c1cbd6f80329a04195263c186615c Mon Sep 17 00:00:00 2001 From: Yevhenii Vlasenko <103361418+YevheniiVlasenko@users.noreply.github.com> Date: Mon, 29 Sep 2025 18:10:10 +0300 Subject: [PATCH 36/44] Update README.md Co-authored-by: Michael Bodnarchuk --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index e15cfc3..16d594b 100644 --- a/README.md +++ b/README.md @@ -294,7 +294,7 @@ Artifacts handling is enabled by default, but it won't affect the run if there a ### Configuration -Artifact handling can be configured in **two different ways**: +Artifacts are stored in external S3 buckets. S3 Access can be configured in **two different ways**: 1. Make configurations on the [Testomat.io](https://app.testomat.io): Choose your project -> click **Settings** button on the left panel -> click **Artifacts** -> Toggle "**Share From 63714dc27d19b39b2245174b17dafbb944b69433 Mon Sep 17 00:00:00 2001 From: Yevhenii Vlasenko <103361418+YevheniiVlasenko@users.noreply.github.com> Date: Mon, 29 Sep 2025 18:10:23 +0300 Subject: [PATCH 37/44] Update README.md Co-authored-by: Michael Bodnarchuk --- README.md | 1 - 1 file changed, 1 deletion(-) diff --git a/README.md b/README.md index 16d594b..318ed54 100644 --- a/README.md +++ b/README.md @@ -290,7 +290,6 @@ Use these oneliners to **download jar and update** ids in one move The Java Reporter supports attaching files (screenshots, logs, videos, etc.) to your test results and uploading them to S3-compatible storage. Artifacts handling is enabled by default, but it won't affect the run if there are no artifacts provided (see options below). -> NOTE: Current version handles artifacts synchronously. ### Configuration From 5b0ecbc90fd030ff41cebafd8eeff2c2c92bfeec Mon Sep 17 00:00:00 2001 From: Yevhenii Vlasenko <103361418+YevheniiVlasenko@users.noreply.github.com> Date: Mon, 29 Sep 2025 18:10:45 +0300 Subject: [PATCH 38/44] Update README.md Co-authored-by: Michael Bodnarchuk --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 318ed54..43c01db 100644 --- a/README.md +++ b/README.md @@ -338,7 +338,7 @@ public class MyTest { } } ``` -Multiple directories can be provided to the `Testomatio.artifact(String ...)` facade method. +Multiple files can be provided to the `Testomatio.artifact(String ...)` method. Please, make sure you provide path to artifact file including its extension. ### How It Works From 7f3cdcb2a053a31f679505f4b99068be57c34173 Mon Sep 17 00:00:00 2001 From: Yevhenii Vlasenko Date: Mon, 29 Sep 2025 18:13:16 +0300 Subject: [PATCH 39/44] Readme minor fix --- README.md | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 43c01db..ddd9b56 100644 --- a/README.md +++ b/README.md @@ -343,10 +343,9 @@ Please, make sure you provide path to artifact file including its extension. ### How It Works -1. **File Validation**: Only existing files with valid paths are processed -2. **S3 Upload**: Files are uploaded to your S3 bucket with organized folder structure -3. **Link Generation**: Public URLs are generated and attached to test results -4. **Thread Safety**: Multiple concurrent tests can safely attach artifacts +1. **S3 Upload**: Files are uploaded to your S3 bucket with organized folder structure +2. **Link Generation**: Public URLs are generated and attached to test results +3. Artifacts are visible at the test info on UI As the result you will see something like this on UI after run completed: From 9c0eaeba32ec28141e6647e8ed09e5962058b084 Mon Sep 17 00:00:00 2001 From: Yevhenii Vlasenko Date: Mon, 29 Sep 2025 18:15:39 +0300 Subject: [PATCH 40/44] Readme minor fix --- README.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index ddd9b56..527f73e 100644 --- a/README.md +++ b/README.md @@ -320,6 +320,7 @@ Environment variables take precedence over server-provided credentials. ### Usage Use the `Testomatio` facade to attach files to your tests: +Multiple files can be provided to the `Testomatio.artifact(String ...)` method. ```java import io.testomat.core.facade.Testomatio; @@ -328,17 +329,16 @@ public class MyTest { @Test public void testWithScreenshot() { - // Your test logic + // Your test logic - // Attach artifacts (screenshots, logs, etc.) - Testomatio.artifact( + // Attach artifacts (screenshots, logs, etc.) + Testomatio.artifact( "/path/to/screenshot.png", "/path/to/test.log" ); } } ``` -Multiple files can be provided to the `Testomatio.artifact(String ...)` method. Please, make sure you provide path to artifact file including its extension. ### How It Works From b6ba521e242fa7a867f4b14bbd845d607e5d20f9 Mon Sep 17 00:00:00 2001 From: Yevhenii Vlasenko Date: Mon, 29 Sep 2025 19:39:38 +0300 Subject: [PATCH 41/44] Minor fixes in JunitListener.java; JunitListenerTest.java update --- .../junit/listener/JunitListener.java | 3 +- .../junit/listener/JunitListenerTest.java | 371 ++++++++++++++++++ 2 files changed, 372 insertions(+), 2 deletions(-) create mode 100644 java-reporter-junit/src/test/java/io/testomat/junit/listener/JunitListenerTest.java diff --git a/java-reporter-junit/src/main/java/io/testomat/junit/listener/JunitListener.java b/java-reporter-junit/src/main/java/io/testomat/junit/listener/JunitListener.java index ff4d341..75728cc 100644 --- a/java-reporter-junit/src/main/java/io/testomat/junit/listener/JunitListener.java +++ b/java-reporter-junit/src/main/java/io/testomat/junit/listener/JunitListener.java @@ -33,8 +33,7 @@ public class JunitListener implements BeforeEachCallback, BeforeAllCallback, AfterAllCallback, AfterEachCallback, TestWatcher { private static final Logger log = LoggerFactory.getLogger(JunitListener.class); - private static final String LISTENING_REQUIRED_PROPERTY_NAME = "testomatio.listening"; - private Boolean artifactDisabled; + private boolean artifactDisabled = false; private final MethodExportManager methodExportManager; private final GlobalRunManager runManager; diff --git a/java-reporter-junit/src/test/java/io/testomat/junit/listener/JunitListenerTest.java b/java-reporter-junit/src/test/java/io/testomat/junit/listener/JunitListenerTest.java new file mode 100644 index 0000000..5d62576 --- /dev/null +++ b/java-reporter-junit/src/test/java/io/testomat/junit/listener/JunitListenerTest.java @@ -0,0 +1,371 @@ +package io.testomat.junit.listener; + +import static io.testomat.core.constants.ArtifactPropertyNames.ARTIFACT_DISABLE_PROPERTY_NAME; +import static io.testomat.core.constants.CommonConstants.FAILED; +import static io.testomat.core.constants.CommonConstants.PASSED; +import static io.testomat.core.constants.CommonConstants.SKIPPED; +import static io.testomat.core.constants.PropertyNameConstants.API_KEY_PROPERTY_NAME; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +import io.testomat.core.artifact.client.AwsService; +import io.testomat.core.propertyconfig.interf.PropertyProvider; +import io.testomat.core.runmanager.GlobalRunManager; +import io.testomat.junit.methodexporter.MethodExportManager; +import io.testomat.junit.reporter.JunitTestReporter; +import java.lang.reflect.Method; +import java.util.Optional; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtensionContext; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; + +class JunitListenerTest { + + @Mock + private MethodExportManager methodExportManager; + + @Mock + private GlobalRunManager runManager; + + @Mock + private JunitTestReporter reporter; + + @Mock + private PropertyProvider propertyProvider; + + @Mock + private AwsService awsService; + + @Mock + private ExtensionContext context; + + @Mock + private Method testMethod; + + private JunitListener listener; + + @BeforeEach + void setUp() { + MockitoAnnotations.openMocks(this); + listener = new JunitListener(methodExportManager, runManager, reporter, propertyProvider, awsService); + } + + @Test + void beforeAll_WhenListeningNotRequired_ShouldSkipIncrement() { + when(propertyProvider.getProperty(API_KEY_PROPERTY_NAME)).thenReturn(null); + + listener.beforeAll(context); + + verify(runManager, never()).incrementSuiteCounter(); + } + + @Test + void beforeAll_WhenListeningRequired_ShouldIncrementSuiteCounter() { + when(propertyProvider.getProperty(API_KEY_PROPERTY_NAME)).thenReturn("test-api-key"); + + listener.beforeAll(context); + + verify(runManager).incrementSuiteCounter(); + } + + @Test + void beforeAll_WhenPropertyProviderThrowsException_ShouldSkipIncrement() { + when(propertyProvider.getProperty(API_KEY_PROPERTY_NAME)).thenThrow(new RuntimeException("Property error")); + + listener.beforeAll(context); + + verify(runManager, never()).incrementSuiteCounter(); + } + + @Test + void afterAll_WhenListeningNotRequired_ShouldSkipProcessing() { + when(propertyProvider.getProperty(API_KEY_PROPERTY_NAME)).thenReturn(null); + + listener.afterAll(context); + + verify(runManager, never()).decrementSuiteCounter(); + verifyNoInteractions(methodExportManager); + } + + @Test + void afterAll_WhenListeningRequired_ShouldDecrementCounterAndExportClass() { + when(propertyProvider.getProperty(API_KEY_PROPERTY_NAME)).thenReturn("test-api-key"); + when(context.getTestClass()).thenReturn(Optional.of(String.class)); + + listener.afterAll(context); + + verify(runManager).decrementSuiteCounter(); + verify(methodExportManager).loadTestBodyForClass(String.class); + } + + @Test + void afterAll_WhenTestClassEmpty_ShouldDecrementCounterOnly() { + when(propertyProvider.getProperty(API_KEY_PROPERTY_NAME)).thenReturn("test-api-key"); + when(context.getTestClass()).thenReturn(Optional.empty()); + + listener.afterAll(context); + + verify(runManager).decrementSuiteCounter(); + verify(methodExportManager, never()).loadTestBodyForClass(any()); + } + + @Test + void beforeEach_ShouldNotPerformAnyActions() { + assertDoesNotThrow(() -> listener.beforeEach(context)); + verifyNoInteractions(runManager, reporter, methodExportManager, awsService); + } + + @Test + void testDisabled_WhenListeningNotRequired_ShouldSkipReporting() { + when(propertyProvider.getProperty(API_KEY_PROPERTY_NAME)).thenReturn(null); + + listener.testDisabled(context, Optional.of("Test disabled")); + + verifyNoInteractions(reporter, methodExportManager); + } + + @Test + void testDisabled_WhenListeningRequired_ShouldReportSkippedWithReason() { + when(propertyProvider.getProperty(API_KEY_PROPERTY_NAME)).thenReturn("test-api-key"); + when(context.getTestClass()).thenReturn(Optional.of(String.class)); + + listener.testDisabled(context, Optional.of("Test disabled")); + + verify(reporter).reportTestResult(context, SKIPPED, "Test disabled"); + verify(methodExportManager).loadTestBodyForClass(String.class); + } + + @Test + void testDisabled_WhenReasonEmpty_ShouldReportSkippedWithDefaultMessage() { + when(propertyProvider.getProperty(API_KEY_PROPERTY_NAME)).thenReturn("test-api-key"); + when(context.getTestClass()).thenReturn(Optional.of(String.class)); + + listener.testDisabled(context, Optional.empty()); + + verify(reporter).reportTestResult(context, SKIPPED, "Test disabled"); + verify(methodExportManager).loadTestBodyForClass(String.class); + } + + @Test + void testSuccessful_WhenListeningNotRequired_ShouldSkipReporting() { + when(propertyProvider.getProperty(API_KEY_PROPERTY_NAME)).thenReturn(null); + + listener.testSuccessful(context); + + verifyNoInteractions(reporter, methodExportManager); + } + + @Test + void testSuccessful_WhenListeningRequired_ShouldReportPassed() { + when(propertyProvider.getProperty(API_KEY_PROPERTY_NAME)).thenReturn("test-api-key"); + when(context.getTestClass()).thenReturn(Optional.of(String.class)); + + listener.testSuccessful(context); + + verify(reporter).reportTestResult(context, PASSED, null); + verify(methodExportManager).loadTestBodyForClass(String.class); + } + + @Test + void testAborted_WhenListeningNotRequired_ShouldSkipReporting() { + when(propertyProvider.getProperty(API_KEY_PROPERTY_NAME)).thenReturn(null); + Throwable cause = new RuntimeException("Test aborted"); + + listener.testAborted(context, cause); + + verifyNoInteractions(reporter, methodExportManager); + } + + @Test + void testAborted_WhenListeningRequired_ShouldReportSkippedWithCauseMessage() { + when(propertyProvider.getProperty(API_KEY_PROPERTY_NAME)).thenReturn("test-api-key"); + when(context.getTestClass()).thenReturn(Optional.of(String.class)); + Throwable cause = new RuntimeException("Test aborted"); + + listener.testAborted(context, cause); + + verify(reporter).reportTestResult(context, SKIPPED, "Test aborted"); + verify(methodExportManager).loadTestBodyForClass(String.class); + } + + @Test + void testFailed_WhenListeningNotRequired_ShouldSkipReporting() { + when(propertyProvider.getProperty(API_KEY_PROPERTY_NAME)).thenReturn(null); + Throwable cause = new AssertionError("Test failed"); + + listener.testFailed(context, cause); + + verifyNoInteractions(reporter, methodExportManager); + } + + @Test + void testFailed_WhenListeningRequired_ShouldReportFailedWithCauseMessage() { + when(propertyProvider.getProperty(API_KEY_PROPERTY_NAME)).thenReturn("test-api-key"); + when(context.getTestClass()).thenReturn(Optional.of(String.class)); + Throwable cause = new AssertionError("Test failed"); + + listener.testFailed(context, cause); + + verify(reporter).reportTestResult(context, FAILED, "Test failed"); + verify(methodExportManager).loadTestBodyForClass(String.class); + } + + @Test + void exportTestClassIfNotProcessed_WhenSameClassCalledMultipleTimes_ShouldExportOnlyOnce() { + when(propertyProvider.getProperty(API_KEY_PROPERTY_NAME)).thenReturn("test-api-key"); + when(context.getTestClass()).thenReturn(Optional.of(String.class)); + + listener.testSuccessful(context); + listener.testSuccessful(context); + listener.testFailed(context, new Exception("Failed")); + + verify(methodExportManager, times(1)).loadTestBodyForClass(String.class); + } + + @Test + void afterEach_WhenArtifactsEnabled_ShouldUploadArtifacts() { + when(context.getDisplayName()).thenReturn("testName"); + when(context.getUniqueId()).thenReturn("uniqueId"); + when(context.getTestMethod()).thenReturn(Optional.of(testMethod)); + + listener.afterEach(context); + + verify(awsService).uploadAllArtifactsForTest(eq("testName"), eq("uniqueId"), any()); + } + + @Test + void defineArtifactsDisabled_WhenPropertyIsNull_ShouldReturnFalse() { + when(propertyProvider.getProperty(ARTIFACT_DISABLE_PROPERTY_NAME)).thenReturn(null); + + JunitListener testListener = new JunitListener(methodExportManager, runManager, reporter, propertyProvider, awsService); + + when(context.getDisplayName()).thenReturn("testName"); + when(context.getUniqueId()).thenReturn("uniqueId"); + when(context.getTestMethod()).thenReturn(Optional.of(testMethod)); + + testListener.afterEach(context); + + verify(awsService).uploadAllArtifactsForTest(anyString(), anyString(), any()); + } + + @Test + void defineArtifactsDisabled_WhenPropertyIsEmpty_ShouldReturnFalse() { + when(propertyProvider.getProperty(ARTIFACT_DISABLE_PROPERTY_NAME)).thenReturn(""); + + JunitListener testListener = new JunitListener(methodExportManager, runManager, reporter, propertyProvider, awsService); + + when(context.getDisplayName()).thenReturn("testName"); + when(context.getUniqueId()).thenReturn("uniqueId"); + when(context.getTestMethod()).thenReturn(Optional.of(testMethod)); + + testListener.afterEach(context); + + verify(awsService).uploadAllArtifactsForTest(anyString(), anyString(), any()); + } + + @Test + void defineArtifactsDisabled_WhenPropertyIsZero_ShouldReturnFalse() { + when(propertyProvider.getProperty(ARTIFACT_DISABLE_PROPERTY_NAME)).thenReturn("0"); + + JunitListener testListener = new JunitListener(methodExportManager, runManager, reporter, propertyProvider, awsService); + + when(context.getDisplayName()).thenReturn("testName"); + when(context.getUniqueId()).thenReturn("uniqueId"); + when(context.getTestMethod()).thenReturn(Optional.of(testMethod)); + + testListener.afterEach(context); + + verify(awsService).uploadAllArtifactsForTest(anyString(), anyString(), any()); + } + + @Test + void defineArtifactsDisabled_WhenPropertyIsTrue_ShouldReturnTrue() { + when(propertyProvider.getProperty(ARTIFACT_DISABLE_PROPERTY_NAME)).thenReturn("true"); + + JunitListener testListener = new JunitListener(methodExportManager, runManager, reporter, propertyProvider, awsService); + + when(context.getDisplayName()).thenReturn("testName"); + when(context.getUniqueId()).thenReturn("uniqueId"); + when(context.getTestMethod()).thenReturn(Optional.of(testMethod)); + + testListener.afterEach(context); + + verify(awsService).uploadAllArtifactsForTest(anyString(), anyString(), any()); + } + + @Test + void defineArtifactsDisabled_WhenPropertyIsAnyNonZeroValue_ShouldReturnTrue() { + when(propertyProvider.getProperty(ARTIFACT_DISABLE_PROPERTY_NAME)).thenReturn("disable"); + + JunitListener testListener = new JunitListener(methodExportManager, runManager, reporter, propertyProvider, awsService); + + when(context.getDisplayName()).thenReturn("testName"); + when(context.getUniqueId()).thenReturn("uniqueId"); + when(context.getTestMethod()).thenReturn(Optional.of(testMethod)); + + testListener.afterEach(context); + + verify(awsService).uploadAllArtifactsForTest(anyString(), anyString(), any()); + } + + @Test + void defineArtifactsDisabled_WhenPropertyProviderThrowsException_ShouldDefaultToFalse() { + when(propertyProvider.getProperty(ARTIFACT_DISABLE_PROPERTY_NAME)).thenThrow(new RuntimeException("Property error")); + + JunitListener testListener = new JunitListener(methodExportManager, runManager, reporter, propertyProvider, awsService); + + when(context.getDisplayName()).thenReturn("testName"); + when(context.getUniqueId()).thenReturn("uniqueId"); + when(context.getTestMethod()).thenReturn(Optional.of(testMethod)); + + testListener.afterEach(context); + + verify(awsService).uploadAllArtifactsForTest(anyString(), anyString(), any()); + } + + @Test + void defaultConstructor_ShouldInitializeAllDependencies() { + JunitListener defaultListener = new JunitListener(); + + assertDoesNotThrow(() -> { + when(context.getTestClass()).thenReturn(Optional.of(String.class)); + defaultListener.testSuccessful(context); + }); + } + + @Test + void testMethodReporting_WhenReporterThrowsException_ShouldPropagateException() { + when(propertyProvider.getProperty(API_KEY_PROPERTY_NAME)).thenReturn("test-api-key"); + when(context.getTestClass()).thenReturn(Optional.of(String.class)); + doThrow(new RuntimeException("Reporter error")).when(reporter).reportTestResult(any(), any(), any()); + + RuntimeException exception = org.junit.jupiter.api.Assertions.assertThrows(RuntimeException.class, + () -> listener.testSuccessful(context)); + + org.junit.jupiter.api.Assertions.assertEquals("Reporter error", exception.getMessage()); + } + + @Test + void testMethodExport_WhenMethodExportManagerThrowsException_ShouldPropagateException() { + when(propertyProvider.getProperty(API_KEY_PROPERTY_NAME)).thenReturn("test-api-key"); + when(context.getTestClass()).thenReturn(Optional.of(String.class)); + doThrow(new RuntimeException("Export error")).when(methodExportManager).loadTestBodyForClass(any()); + + RuntimeException exception = org.junit.jupiter.api.Assertions.assertThrows(RuntimeException.class, + () -> listener.testSuccessful(context)); + + org.junit.jupiter.api.Assertions.assertEquals("Export error", exception.getMessage()); + verify(reporter).reportTestResult(context, PASSED, null); + } +} \ No newline at end of file From 969fd95953ad0eb041144e3619685e558942a9e0 Mon Sep 17 00:00:00 2001 From: Yevhenii Vlasenko Date: Mon, 29 Sep 2025 19:40:18 +0300 Subject: [PATCH 42/44] fixed namespaces to run github workflow --- java-reporter-core/pom.xml | 4 ++-- java-reporter-cucumber/pom.xml | 4 ++-- java-reporter-junit/pom.xml | 4 ++-- java-reporter-testng/pom.xml | 4 ++-- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/java-reporter-core/pom.xml b/java-reporter-core/pom.xml index 9e829c7..54215e1 100644 --- a/java-reporter-core/pom.xml +++ b/java-reporter-core/pom.xml @@ -5,9 +5,9 @@ http://maven.apache.org/xsd/maven-4.0.0.xsd"> 4.0.0 - coretest + io.testomat java-reporter-core - coretest + 0.7.8 jar Testomat.io Reporter Core diff --git a/java-reporter-cucumber/pom.xml b/java-reporter-cucumber/pom.xml index b8c8d31..0dd59c9 100644 --- a/java-reporter-cucumber/pom.xml +++ b/java-reporter-cucumber/pom.xml @@ -4,9 +4,9 @@ xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> 4.0.0 - cucumbertest + io.testomat java-reporter-cucumber - cucumbertest + 0.7.2 jar Testomat.io Java Reporter Cucumber diff --git a/java-reporter-junit/pom.xml b/java-reporter-junit/pom.xml index 8e1a1e4..f889ac2 100644 --- a/java-reporter-junit/pom.xml +++ b/java-reporter-junit/pom.xml @@ -4,9 +4,9 @@ xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> 4.0.0 - junittest + io.testomat java-reporter-junit - junittest + 0.7.5 jar Testomat.io Java Reporter JUnit diff --git a/java-reporter-testng/pom.xml b/java-reporter-testng/pom.xml index 7978044..5fef7eb 100644 --- a/java-reporter-testng/pom.xml +++ b/java-reporter-testng/pom.xml @@ -4,9 +4,9 @@ xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> 4.0.0 - testngtest + io.testomat java-reporter-testng - testngtest + 0.7.8 jar Testomat.io Java Reporter TestNG From 2ed01e5a88179cae2e1e524bb2121f11eb97b990 Mon Sep 17 00:00:00 2001 From: Yevhenii Vlasenko Date: Mon, 29 Sep 2025 20:17:32 +0300 Subject: [PATCH 43/44] Changed version in core pom for separate deploy --- java-reporter-core/pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/java-reporter-core/pom.xml b/java-reporter-core/pom.xml index 54215e1..594f464 100644 --- a/java-reporter-core/pom.xml +++ b/java-reporter-core/pom.xml @@ -7,7 +7,7 @@ io.testomat java-reporter-core - 0.7.8 + 0.7.9 jar Testomat.io Reporter Core From bd907f0e7356553201aafd8f34e84e1313ef54e2 Mon Sep 17 00:00:00 2001 From: Yevhenii Vlasenko Date: Mon, 29 Sep 2025 20:19:34 +0300 Subject: [PATCH 44/44] Changed versions in framework modules poms for auto deploy --- java-reporter-cucumber/pom.xml | 6 +++--- java-reporter-junit/pom.xml | 6 +++--- java-reporter-testng/pom.xml | 6 +++--- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/java-reporter-cucumber/pom.xml b/java-reporter-cucumber/pom.xml index 0dd59c9..96668ed 100644 --- a/java-reporter-cucumber/pom.xml +++ b/java-reporter-cucumber/pom.xml @@ -6,7 +6,7 @@ io.testomat java-reporter-cucumber - 0.7.2 + 0.7.9 jar Testomat.io Java Reporter Cucumber @@ -48,9 +48,9 @@ - coretest + io.testomat java-reporter-core - coretest + 0.7.9 io.cucumber diff --git a/java-reporter-junit/pom.xml b/java-reporter-junit/pom.xml index f889ac2..f80c1ab 100644 --- a/java-reporter-junit/pom.xml +++ b/java-reporter-junit/pom.xml @@ -6,7 +6,7 @@ io.testomat java-reporter-junit - 0.7.5 + 0.7.9 jar Testomat.io Java Reporter JUnit @@ -47,9 +47,9 @@ - coretest + io.testomat java-reporter-core - coretest + 0.7.9 org.junit.jupiter diff --git a/java-reporter-testng/pom.xml b/java-reporter-testng/pom.xml index 5fef7eb..7fdca92 100644 --- a/java-reporter-testng/pom.xml +++ b/java-reporter-testng/pom.xml @@ -6,7 +6,7 @@ io.testomat java-reporter-testng - 0.7.8 + 0.7.9 jar Testomat.io Java Reporter TestNG @@ -44,9 +44,9 @@ - coretest + io.testomat java-reporter-core - coretest + 0.7.9 org.testng