diff --git a/README.md b/README.md
index f681263..6fb3a33 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) | | | |
@@ -100,7 +100,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.
@@ -279,7 +281,75 @@ Use these oneliners to **download jar and update** ids in one move
---
-## 💡 Usage Examples
+## 📎 Test Artifacts Support
+
+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).
+
+### Configuration
+
+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
+ credentials**..."
+
+
+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.
+
+### 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;
+
+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"
+ );
+ }
+}
+```
+Please, make sure you provide path to artifact file including its extension.
+
+### How It Works
+
+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:
+
+
+---
+
+## 💡 Library Usage Examples
### Basic Usage
diff --git a/img/artifactExample.png b/img/artifactExample.png
new file mode 100644
index 0000000..95e848c
Binary files /dev/null and b/img/artifactExample.png differ
diff --git a/img/artifactsOnServerTurnOn.png b/img/artifactsOnServerTurnOn.png
new file mode 100644
index 0000000..5cb67f7
Binary files /dev/null and b/img/artifactsOnServerTurnOn.png differ
diff --git a/java-reporter-core/pom.xml b/java-reporter-core/pom.xml
index e349833..594f464 100644
--- a/java-reporter-core/pom.xml
+++ b/java-reporter-core/pom.xml
@@ -7,7 +7,7 @@
io.testomat
java-reporter-core
- 0.6.8
+ 0.7.9
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/ArtifactLinkData.java b/java-reporter-core/src/main/java/io/testomat/core/artifact/ArtifactLinkData.java
new file mode 100644
index 0000000..4e4c5d4
--- /dev/null
+++ b/java-reporter-core/src/main/java/io/testomat/core/artifact/ArtifactLinkData.java
@@ -0,0 +1,75 @@
+package io.testomat.core.artifact;
+
+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;
+ private final String testName;
+
+ 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;
+ this.testId = testId;
+ 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/artifact/ArtifactLinkDataStorage.java b/java-reporter-core/src/main/java/io/testomat/core/artifact/ArtifactLinkDataStorage.java
new file mode 100644
index 0000000..805bec7
--- /dev/null
+++ b/java-reporter-core/src/main/java/io/testomat/core/artifact/ArtifactLinkDataStorage.java
@@ -0,0 +1,13 @@
+package io.testomat.core.artifact;
+
+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/artifact/LinkUploadBodyBuilder.java b/java-reporter-core/src/main/java/io/testomat/core/artifact/LinkUploadBodyBuilder.java
new file mode 100644
index 0000000..5c85e7e
--- /dev/null
+++ b/java-reporter-core/src/main/java/io/testomat/core/artifact/LinkUploadBodyBuilder.java
@@ -0,0 +1,45 @@
+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;
+import com.fasterxml.jackson.databind.node.ObjectNode;
+import java.util.List;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+public class LinkUploadBodyBuilder {
+ private static final Logger log = LoggerFactory.getLogger(LinkUploadBodyBuilder.class);
+
+ public String buildLinkUploadRequestBody(List storedLinkData, String apiKey) {
+ ObjectMapper mapper = new ObjectMapper();
+ ObjectNode rootNode = mapper.createObjectNode();
+ ArrayNode testsArray = mapper.createArrayNode();
+
+ for (ArtifactLinkData data : storedLinkData) {
+ ObjectNode testNode = mapper.createObjectNode();
+ 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);
+ }
+
+ 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;
+ }
+}
diff --git a/java-reporter-core/src/main/java/io/testomat/core/artifact/ReportedTestStorage.java b/java-reporter-core/src/main/java/io/testomat/core/artifact/ReportedTestStorage.java
new file mode 100644
index 0000000..2668ac7
--- /dev/null
+++ b/java-reporter-core/src/main/java/io/testomat/core/artifact/ReportedTestStorage.java
@@ -0,0 +1,52 @@
+package io.testomat.core.artifact;
+
+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<>();
+
+ /**
+ * Stores test execution data.
+ *
+ * @param body test data map containing test results and metadata
+ */
+ public static void store(Map body) {
+ STORAGE.add(body);
+ log.debug("Stored body: {}", 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()
+ .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/TempArtifactDirectoriesStorage.java b/java-reporter-core/src/main/java/io/testomat/core/artifact/TempArtifactDirectoriesStorage.java
new file mode 100644
index 0000000..d2f24fe
--- /dev/null
+++ b/java-reporter-core/src/main/java/io/testomat/core/artifact/TempArtifactDirectoriesStorage.java
@@ -0,0 +1,16 @@
+package io.testomat.core.artifact;
+
+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);
+
+ public static void store(String dir) {
+ DIRECTORIES.get().add(dir);
+ }
+}
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..df4c0c5
--- /dev/null
+++ b/java-reporter-core/src/main/java/io/testomat/core/artifact/client/AwsClient.java
@@ -0,0 +1,39 @@
+package io.testomat.core.artifact.client;
+
+import org.slf4j.Logger;
+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;
+ private final S3ClientFactory clientFactory;
+
+ public AwsClient() {
+ this.clientFactory = new S3ClientFactory();
+ }
+
+ /**
+ * Test Constructor
+ */
+ public AwsClient(S3ClientFactory s3ClientFactory) {
+ this.clientFactory = s3ClientFactory;
+ }
+
+ /**
+ * Returns a configured S3Client instance using lazy initialization.
+ *
+ * @return configured S3Client instance
+ */
+ public S3Client getS3Client() {
+ if (s3Client == null) {
+ s3Client = clientFactory.createS3Client();
+ return s3Client;
+ }
+ 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
new file mode 100644
index 0000000..7a7d0aa
--- /dev/null
+++ b/java-reporter-core/src/main/java/io/testomat/core/artifact/client/AwsService.java
@@ -0,0 +1,240 @@
+package io.testomat.core.artifact.client;
+
+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;
+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.Map;
+import java.util.Objects;
+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;
+
+/**
+ * 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<>();
+
+ 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;
+
+ public AwsService() {
+ this.keyGenerator = new ArtifactKeyGenerator();
+ this.awsClient = new AwsClient();
+ this.urlGenerator = new ArtifactUrlGenerator();
+ log.debug("AWS Service initialized");
+ }
+
+ public AwsService(ArtifactKeyGenerator keyGenerator, AwsClient awsClient,
+ ArtifactUrlGenerator urlGenerator) {
+ this.keyGenerator = keyGenerator;
+ this.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
+ * @throws IllegalArgumentException if any parameter is null
+ */
+ public void uploadAllArtifactsForTest(String testName, String rid, String testId) {
+ 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 = 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 : artifactDirectories) {
+ String key = keyGenerator.generateKey(dir, rid, testName);
+ uploadArtifact(dir, key, credentials);
+ uploadedLinks.add(urlGenerator.generateUrl(credentials.getBucket(), key));
+ }
+
+ 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) {
+ 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 {
+ 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, e);
+ }
+ }
+
+ private void uploadWithAclStrategy(Path path, String key, S3Credentials credentials, byte[] content) {
+ String bucketName = credentials.getBucket();
+ Boolean supportsAcl = bucketAclSupport.get(bucketName);
+
+ if (supportsAcl == null) {
+ detectAndUpload(path, key, credentials, content, bucketName);
+ } else if (supportsAcl) {
+ 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 {
+ bucketAclSupport.put(bucketName, false);
+ performUploadWithoutAcl(path, key, credentials, content);
+ }
+ }
+
+ private boolean tryUploadWithAcl(Path path, String key, S3Credentials credentials, byte[] content) {
+ try {
+ PutObjectRequest request = buildUploadRequestWithAcl(credentials, key);
+ performS3Upload(request, content);
+ log.debug("S3 upload completed successfully with ACL for file: {}", path);
+ return true;
+ } catch (S3Exception e) {
+ return handleS3Exception(e, path, credentials, key);
+ } catch (Exception e) {
+ handleGenericException(e, path, credentials, key);
+ return false;
+ }
+ }
+
+ private void performUploadWithAcl(Path path, String key, S3Credentials credentials, byte[] content) {
+ try {
+ 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();
+ }
+
+ 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 (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);
+ }
+ }
+
+ private boolean isAclNotSupportedError(S3Exception e) {
+ return (e.statusCode() == 400 &&
+ (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(ERROR_MESSAGE_NO_ACL))));
+ }
+}
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..8f8a0db
--- /dev/null
+++ b/java-reporter-core/src/main/java/io/testomat/core/artifact/client/S3ClientFactory.java
@@ -0,0 +1,75 @@
+package io.testomat.core.artifact.client;
+
+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.regions.Region;
+import software.amazon.awssdk.services.s3.S3Client;
+import software.amazon.awssdk.services.s3.S3ClientBuilder;
+import software.amazon.awssdk.services.s3.S3Configuration;
+
+/**
+ * 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();
+
+ 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 {
+ 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 {
+ 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);
+ }
+
+ S3Configuration s3Config = S3Configuration.builder()
+ .pathStyleAccessEnabled(s3Credentials.isForcePath())
+ .build();
+ builder.serviceConfiguration(s3Config);
+ } else {
+ 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/CredentialsManager.java b/java-reporter-core/src/main/java/io/testomat/core/artifact/credential/CredentialsManager.java
new file mode 100644
index 0000000..5176603
--- /dev/null
+++ b/java-reporter-core/src/main/java/io/testomat/core/artifact/credential/CredentialsManager.java
@@ -0,0 +1,148 @@
+package io.testomat.core.artifact.credential;
+
+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;
+import static io.testomat.core.constants.CredentialConstants.SECRET_ACCESS_KEY;
+import static io.testomat.core.constants.CredentialConstants.SHARED;
+
+import io.testomat.core.propertyconfig.impl.PropertyProviderFactoryImpl;
+import io.testomat.core.propertyconfig.interf.PropertyProvider;
+import java.util.Map;
+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();
+
+
+ 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");
+
+ if (credsFromServer == null || credsFromServer.isEmpty()) {
+ log.warn("Received null or empty credentials map");
+ return;
+ }
+ 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)));
+
+ populateCredentialField(PRIVATE_ARTIFACTS_PROPERTY_NAME, PRESIGN, credsFromServer, "Presign",
+ value -> credentials.setPresign(getBooleanValue(value)));
+
+ populateCredentialField(SECRET_ACCESS_KEY_PROPERTY_NAME, SECRET_ACCESS_KEY, credsFromServer, "SecretAccessKey",
+ value -> credentials.setSecretAccessKey(getStringValue(value)));
+
+ populateCredentialField(ACCESS_KEY_PROPERTY_NAME, ACCESS_KEY_ID, credsFromServer, "AccessKey",
+ value -> credentials.setAccessKeyId(getStringValue(value)));
+
+ populateCredentialField(BUCKET_PROPERTY_NAME, BUCKET, credsFromServer, "Bucket",
+ value -> credentials.setBucket(getStringValue(value)));
+
+ populateCredentialField(REGION_PROPERTY_NAME, REGION, credsFromServer, "Region",
+ value -> credentials.setRegion(getStringValue(value)));
+
+ credentials.setIam(getBooleanValue(credsFromServer.get(IAM)));
+ credentials.setShared(getBooleanValue(credsFromServer.get(SHARED)));
+
+ logCredentialsInitializationResult();
+ }
+
+ 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 Object getPropertyFromEnv(String propertyName) {
+ try {
+ return provider.getProperty(propertyName);
+ } catch (Exception e) {
+ return null;
+ }
+ }
+
+ 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(),
+ 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/artifact/credential/CredentialsValidationService.java b/java-reporter-core/src/main/java/io/testomat/core/artifact/credential/CredentialsValidationService.java
new file mode 100644
index 0000000..ed93507
--- /dev/null
+++ b/java-reporter-core/src/main/java/io/testomat/core/artifact/credential/CredentialsValidationService.java
@@ -0,0 +1,70 @@
+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.services.s3.S3Client;
+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;
+
+ public CredentialsValidationService() {
+ this.awsClient = new AwsClient();
+ }
+
+ 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");
+ 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 = awsClient.getS3Client();
+ 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..6d7c7d0
--- /dev/null
+++ b/java-reporter-core/src/main/java/io/testomat/core/artifact/credential/S3Credentials.java
@@ -0,0 +1,89 @@
+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;
+ 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
diff --git a/java-reporter-core/src/main/java/io/testomat/core/artifact/manager/ArtifactManager.java b/java-reporter-core/src/main/java/io/testomat/core/artifact/manager/ArtifactManager.java
new file mode 100644
index 0000000..19929b1
--- /dev/null
+++ b/java-reporter-core/src/main/java/io/testomat/core/artifact/manager/ArtifactManager.java
@@ -0,0 +1,44 @@
+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;
+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);
+
+ public void storeDirectories(String... directories) {
+ for (String dir : directories) {
+ if (isValidFilePath(dir)) {
+ TempArtifactDirectoriesStorage.store(dir);
+ } else {
+ log.info("Invalid artifact path provided: {}", dir);
+ }
+ }
+ }
+
+ private 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/util/ArtifactKeyGenerator.java b/java-reporter-core/src/main/java/io/testomat/core/artifact/util/ArtifactKeyGenerator.java
new file mode 100644
index 0000000..a2b1158
--- /dev/null
+++ b/java-reporter-core/src/main/java/io/testomat/core/artifact/util/ArtifactKeyGenerator.java
@@ -0,0 +1,25 @@
+package io.testomat.core.artifact.util;
+
+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 = "/";
+
+ 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/util/ArtifactUrlGenerator.java b/java-reporter-core/src/main/java/io/testomat/core/artifact/util/ArtifactUrlGenerator.java
new file mode 100644
index 0000000..5003d36
--- /dev/null
+++ b/java-reporter-core/src/main/java/io/testomat/core/artifact/util/ArtifactUrlGenerator.java
@@ -0,0 +1,31 @@
+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;
+
+/**
+ * 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;
+
+
+ public ArtifactUrlGenerator() {
+ this.awsClient = new AwsClient();
+ }
+
+ public ArtifactUrlGenerator(AwsClient awsClient, S3Presigner s3Presigner) {
+ this.awsClient = awsClient;
+ }
+
+ 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..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.
*
@@ -47,4 +49,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 61%
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 bf31c6f..e05b3bb 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,10 +1,19 @@
package io.testomat.core.client;
+import static io.testomat.core.constants.CommonConstants.REPORTER_VERSION;
+import static io.testomat.core.constants.CommonConstants.RESPONSE_UID_KEY;
+
+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;
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;
@@ -15,22 +24,22 @@
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();
private final String apiKey;
private final CustomHttpClient client;
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.
@@ -40,9 +49,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;
@@ -56,16 +65,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")) {
+ 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));
- return responseBody.get(RESPONSE_UID_KEY);
+ return responseBody.get(RESPONSE_UID_KEY).toString();
}
@Override
@@ -95,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) {
@@ -103,6 +118,20 @@ 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 {
@@ -118,13 +147,28 @@ public void finishTestRun(String uid, float duration) {
}
}
- private void logAndPrintUrls(Map responseBody) {
- String publicUrl = responseBody.get("public_url");
+ public void uploadLinksToTestomatio(String uid) {
+
+ String requestBody = linkUploadBodyBuilder.buildLinkUploadRequestBody(
+ ArtifactLinkDataStorage.ARTEFACT_LINK_DATA_STORAGE, apiKey);
+
+ String url = urlBuilder.buildReportTestUrl(uid);
+ log.debug("-> REQUEST BODY: {}", requestBody);
+
+ 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");
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/client/request/NativeRequestBodyBuilder.java b/java-reporter-core/src/main/java/io/testomat/core/client/request/NativeRequestBodyBuilder.java
index 0931488..a8978d0 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.artifact.ReportedTestStorage;
import io.testomat.core.constants.ApiRequestFields;
import io.testomat.core.exception.FailedToCreateRunBodyException;
import io.testomat.core.model.TestResult;
@@ -69,6 +70,8 @@ public String buildCreateRunBody(String title) {
body.put("access_event", "publish");
}
+ body.put("overwrite", "true");
+
return objectMapper.writeValueAsString(body);
} catch (JsonProcessingException e) {
@@ -97,6 +100,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(
@@ -106,6 +119,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.
@@ -142,6 +160,8 @@ private Map buildTestResultMap(TestResult result) {
body.put("create", TRUE);
}
+ body.put("overwrite", "true");
+ ReportedTestStorage.store(body);
return body;
}
@@ -163,8 +183,7 @@ private String getPropertySafely(String propertyName) {
private boolean getCreateParam() {
try {
- String property = provider.getProperty(CREATE_TEST_PROPERTY_NAME);
- return property != null && !property.equalsIgnoreCase("0");
+ 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..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
*/
@@ -47,4 +48,9 @@ String buildBatchTestReportBody(List results, String apiKey)
* @throws JsonProcessingException if JSON serialization fails
*/
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/constants/ArtifactPropertyNames.java b/java-reporter-core/src/main/java/io/testomat/core/constants/ArtifactPropertyNames.java
new file mode 100644
index 0000000..7e4e48b
--- /dev/null
+++ b/java-reporter-core/src/main/java/io/testomat/core/constants/ArtifactPropertyNames.java
@@ -0,0 +1,15 @@
+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 FORCE_PATH_PROPERTY_NAME = "s3.force-path-style";
+
+ 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";
+}
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/constants/CredentialConstants.java b/java-reporter-core/src/main/java/io/testomat/core/constants/CredentialConstants.java
new file mode 100644
index 0000000..e90dbca
--- /dev/null
+++ b/java-reporter-core/src/main/java/io/testomat/core/constants/CredentialConstants.java
@@ -0,0 +1,16 @@
+package io.testomat.core.constants;
+
+public class CredentialConstants {
+ 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";
+ public static final String ENDPOINT = "ENDPOINT";
+ public static final String FORCE_PATH = "FORCE_PATH_STYLE";
+
+ private CredentialConstants() {
+ }
+}
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/constants/PropertyValuesConstants.java b/java-reporter-core/src/main/java/io/testomat/core/constants/PropertyValuesConstants.java
index 5a00356..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
@@ -1,8 +1,8 @@
package io.testomat.core.constants;
public class PropertyValuesConstants {
- public static final int DEFAULT_BATCH_SIZE = 10;
- public static final int DEFAULT_FLUSH_INTERVAL_SECONDS = 60000;
+ public static final int DEFAULT_BATCH_SIZE = 100000;
+ 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-core/src/main/java/io/testomat/core/exception/ArtifactManagementException.java b/java-reporter-core/src/main/java/io/testomat/core/exception/ArtifactManagementException.java
new file mode 100644
index 0000000..0781301
--- /dev/null
+++ b/java-reporter-core/src/main/java/io/testomat/core/exception/ArtifactManagementException.java
@@ -0,0 +1,11 @@
+package io.testomat.core.exception;
+
+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/facade/ServiceRegistryUtil.java b/java-reporter-core/src/main/java/io/testomat/core/facade/ServiceRegistryUtil.java
new file mode 100644
index 0000000..9a50675
--- /dev/null
+++ b/java-reporter-core/src/main/java/io/testomat/core/facade/ServiceRegistryUtil.java
@@ -0,0 +1,31 @@
+package io.testomat.core.facade;
+
+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 -> {
+ 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/facade/Testomatio.java b/java-reporter-core/src/main/java/io/testomat/core/facade/Testomatio.java
new file mode 100644
index 0000000..6f84a18
--- /dev/null
+++ b/java-reporter-core/src/main/java/io/testomat/core/facade/Testomatio.java
@@ -0,0 +1,19 @@
+package io.testomat.core.facade;
+
+import io.testomat.core.artifact.manager.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);
+ }
+}
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..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
@@ -1,8 +1,12 @@
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.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;
import io.testomat.core.client.ClientFactory;
@@ -23,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<>();
@@ -34,42 +39,103 @@ 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.
* Thread-safe operation that ensures single initialization.
*/
public synchronized void initializeIfNeeded() {
- if (runUid.get() != null) {
+ if (runUid.get() != null || isReportingDisabled()) {
return;
}
try {
- ClientFactory clientFactory = TestomatClientFactory.getClientFactory();
- ApiInterface client = clientFactory.createClient();
- String uid = getCustomRunUid(client);
+ log.debug("Client factory initialized successfully");
+
+ ApiInterface client = this.clientFactory.createClient();
+ log.debug("Client created successfully");
+
+ 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);
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));
}
@@ -128,41 +194,98 @@ 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() {
+ 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.getAndSet(null);
ApiInterface client = apiClient.getAndSet(null);
-
- if (uid != null && client != null) {
+ if (client != null) {
try {
float duration = (System.currentTimeMillis() - startTime) / 1000.0f;
client.finishTestRun(uid, duration);
- log.debug("Test run finished: {}", uid);
+ 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);
} 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 getCustomRunUid(ApiInterface client) throws IOException {
+ private String defineRunId(ApiInterface client) throws IOException {
String customUid;
try {
customUid = provider.getProperty(CUSTOM_RUN_UID_PROPERTY_NAME);
@@ -171,4 +294,15 @@ private String getCustomRunUid(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;
+ }
+ }
}
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-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
diff --git a/java-reporter-cucumber/pom.xml b/java-reporter-cucumber/pom.xml
index 44d768c..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
@@ -50,7 +50,7 @@
io.testomat
java-reporter-core
- 0.7.2
+ 0.7.9
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..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
@@ -115,18 +115,7 @@ public String extractTitle(TestCaseFinished event) {
*/
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;
-
+ 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 5f812ef..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
@@ -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;
}
/**
@@ -72,6 +81,20 @@ void handleTestCaseFinished(TestCaseFinished event) {
String testName = event.getTestCase() != null ? event.getTestCase().getName()
: "Unknown Test";
throw new ReportTestResultException("Failed to report test result for: " + testName, e);
+ } finally {
+ afterEach(event);
}
}
+
+ /**
+ * 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/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 5be3777..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
@@ -5,29 +5,38 @@
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;
@@ -51,9 +60,8 @@ class CucumberListenerTest {
@BeforeEach
void setUp() {
MockitoAnnotations.openMocks(this);
- listener = new CucumberListener(resultConstructor, runManager);
+ listener = new CucumberListener(resultConstructor, runManager, awsService, dataExtractor);
}
-
@Test
void shouldCreateDefaultConstructor() {
CucumberListener defaultListener = new CucumberListener();
@@ -70,40 +78,43 @@ void shouldRegisterEventHandlers() {
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 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);
+ 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);
@@ -111,100 +122,120 @@ void shouldHandleTestCaseFinishedWhenRunManagerIsActive() {
// Then
verify(resultConstructor).constructTestRunResult(testCaseFinished);
verify(runManager).reportTest(testResult);
+ verify(awsService).uploadAllArtifactsForTest("Test Title", testCaseId.toString(), "@T12345");
}
-
@Test
- void shouldNotHandleTestCaseFinishedWhenRunManagerIsInactive() {
+ void shouldWrapExceptionWhenConstructorFails() {
// Given
- when(runManager.isActive()).thenReturn(false);
+ 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
- listener.handleTestCaseFinished(testCaseFinished);
+ // 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
- verify(resultConstructor, never()).constructTestRunResult(any());
- verify(runManager, never()).reportTest(any());
+ assertTrue(listener instanceof io.cucumber.plugin.Plugin);
+ assertTrue(listener instanceof io.cucumber.plugin.EventListener);
}
@Test
- void shouldThrowExceptionWhenEventIsNull() {
+ void shouldVerifyEventPublisherRegistration() {
// Given
- when(runManager.isActive()).thenReturn(true);
-
- // When & Then
- CucumberListenerException exception = assertThrows(
- CucumberListenerException.class,
- () -> listener.handleTestCaseFinished(null)
- );
+ listener.setEventPublisher(eventPublisher);
- assertEquals("The listener received null event", exception.getMessage());
+ // Then
+ verify(eventPublisher, times(3)).registerHandlerFor(any(), any());
}
@Test
- void shouldWrapExceptionWhenConstructorFails() {
+ void shouldCallAfterEachWithCorrectParameters() {
// Given
- RuntimeException originalException = new RuntimeException("Constructor error");
when(runManager.isActive()).thenReturn(true);
+ UUID testCaseId = UUID.randomUUID();
when(testCaseFinished.getTestCase()).thenReturn(testCase);
when(testCase.getName()).thenReturn("Test Scenario");
- when(resultConstructor.constructTestRunResult(testCaseFinished))
- .thenThrow(originalException);
+ 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 & Then
- ReportTestResultException exception = assertThrows(
- ReportTestResultException.class,
- () -> listener.handleTestCaseFinished(testCaseFinished)
- );
+ // When
+ 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
+ // Then
+ verify(dataExtractor).extractTitle(testCaseFinished);
+ verify(dataExtractor).extractTestId(testCaseFinished);
+ verify(awsService).uploadAllArtifactsForTest("Test Title", testCaseId.toString(), "@T12345");
}
@Test
- void shouldWrapExceptionWhenReportTestFails() {
+ void shouldHandleNullTestIdInAfterEach() {
// Given
- RuntimeException originalException = new RuntimeException("Report error");
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);
- doThrow(originalException).when(runManager).reportTest(testResult);
+ when(dataExtractor.extractTitle(testCaseFinished)).thenReturn("Test Title");
+ when(dataExtractor.extractTestId(testCaseFinished)).thenReturn(null);
- // When & Then
- ReportTestResultException exception = assertThrows(
- ReportTestResultException.class,
- () -> listener.handleTestCaseFinished(testCaseFinished)
- );
+ // When
+ 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
+ // Then
+ verify(awsService).uploadAllArtifactsForTest("Test Title", testCaseId.toString(), null);
}
@Test
- void shouldHandleUnknownTestWhenTestCaseIsNull() {
+ void shouldHandleNullTitleInAfterEach() {
// Given
when(runManager.isActive()).thenReturn(true);
- when(testCaseFinished.getTestCase()).thenReturn(null);
- when(resultConstructor.constructTestRunResult(testCaseFinished))
- .thenThrow(new RuntimeException("Constructor error"));
+ 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 & Then
- ReportTestResultException exception = assertThrows(
- ReportTestResultException.class,
- () -> listener.handleTestCaseFinished(testCaseFinished)
- );
+ // When
+ listener.handleTestCaseFinished(testCaseFinished);
- assertTrue(exception.getMessage().contains("Failed to report test result for: Unknown Test"));
+ // Then
+ verify(awsService).uploadAllArtifactsForTest(null, testCaseId.toString(), "@T12345");
}
@Test
- void shouldHandleUnknownTestWhenTestNameIsNull() {
+ 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(null);
- when(resultConstructor.constructTestRunResult(testCaseFinished))
- .thenThrow(new RuntimeException("Constructor error"));
+ 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(
@@ -212,23 +243,8 @@ void shouldHandleUnknownTestWhenTestNameIsNull() {
() -> 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());
+ // 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
diff --git a/java-reporter-junit/pom.xml b/java-reporter-junit/pom.xml
index 9a6fb85..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
@@ -49,7 +49,7 @@
io.testomat
java-reporter-core
- 0.7.5
+ 0.7.9
org.junit.jupiter
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);
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 4401a58..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
@@ -1,19 +1,23 @@
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 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.junit.extractor.JunitMetaDataExtractor;
import io.testomat.junit.methodexporter.MethodExportManager;
import io.testomat.junit.reporter.JunitTestReporter;
import java.util.Optional;
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;
@@ -26,15 +30,16 @@
* 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 boolean artifactDisabled = false;
private final MethodExportManager methodExportManager;
private final GlobalRunManager runManager;
private final JunitTestReporter reporter;
private final PropertyProvider provider;
-
+ private final AwsService awsService;
private final Set processedClasses;
public JunitListener() {
@@ -42,8 +47,10 @@ public JunitListener() {
this.runManager = GlobalRunManager.getInstance();
this.reporter = new JunitTestReporter();
this.processedClasses = ConcurrentHashMap.newKeySet();
+ this.awsService = new AwsService();
this.provider =
PropertyProviderFactoryImpl.getPropertyProviderFactory().getPropertyProvider();
+ this.artifactDisabled = defineArtifactsDisabled();
}
/**
@@ -57,12 +64,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
@@ -152,4 +161,27 @@ private boolean isListeningRequired() {
return false;
}
}
+
+ @Override
+ public void afterEach(ExtensionContext context) {
+ if (!artifactDisabled) {
+ 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;
+ }
}
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 adf3eef..5d62576 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,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 org.junit.jupiter.api.Assertions.assertNotNull;
+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.exception.PropertyNotFoundException;
+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.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;
+ private MethodExportManager methodExportManager;
@Mock
- private GlobalRunManager mockRunManager;
+ private GlobalRunManager runManager;
@Mock
- private JunitTestReporter mockReporter;
+ private JunitTestReporter reporter;
@Mock
- private PropertyProvider mockPropertyProvider;
+ private PropertyProvider propertyProvider;
@Mock
- private ExtensionContext mockExtensionContext;
+ private AwsService awsService;
- private JunitListener junitListener;
+ @Mock
+ private ExtensionContext context;
- // Test classes for testing different scenarios
- public static class TestClassA {
- }
+ @Mock
+ private Method testMethod;
- public static class TestClassB {
- }
+ private JunitListener listener;
@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
- );
+ listener = new JunitListener(methodExportManager, runManager, reporter, propertyProvider, awsService);
}
- @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);
- }
+ @Test
+ void beforeAll_WhenListeningNotRequired_ShouldSkipIncrement() {
+ when(propertyProvider.getProperty(API_KEY_PROPERTY_NAME)).thenReturn(null);
+
+ listener.beforeAll(context);
+
+ verify(runManager, never()).incrementSuiteCounter();
}
- @Nested
- @DisplayName("Listening Required Logic Tests")
- class ListeningRequiredLogicTests {
-
- @Test
- @DisplayName("Should return false when property throws PropertyNotFoundException")
- void shouldReturnFalseWhenPropertyThrowsPropertyNotFoundException() {
- // Given
- when(mockPropertyProvider.getProperty("testomatio"))
- .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"))
- .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")).thenReturn(null);
-
- // When
- junitListener.beforeAll(mockExtensionContext);
-
- // Then - should do early return, not call runManager
- verify(mockRunManager, never()).incrementSuiteCounter();
- }
+ @Test
+ void beforeAll_WhenListeningRequired_ShouldIncrementSuiteCounter() {
+ when(propertyProvider.getProperty(API_KEY_PROPERTY_NAME)).thenReturn("test-api-key");
+
+ listener.beforeAll(context);
+
+ verify(runManager).incrementSuiteCounter();
}
- @Nested
- @DisplayName("BeforeAll Tests")
- class BeforeAllTests {
-
- @Test
- @DisplayName("Should increment suite counter when listening is required")
- void shouldIncrementSuiteCounterWhenListeningIsRequired() {
- // Given
- when(mockPropertyProvider.getProperty("testomatio")).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")).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")).thenReturn("enabled");
-
- // When
- junitListener.beforeAll(mockExtensionContext);
- junitListener.beforeAll(mockExtensionContext);
- junitListener.beforeAll(mockExtensionContext);
-
- // Then
- verify(mockRunManager, times(3)).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();
}
- @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")).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")).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")).thenReturn("true");
- when(mockExtensionContext.getTestClass()).thenReturn(Optional.empty());
-
- // When
- junitListener.afterAll(mockExtensionContext);
-
- // Then
- verify(mockMethodExportManager, never()).loadTestBodyForClass(any());
- verify(mockRunManager, times(1)).decrementSuiteCounter();
- }
+ @Test
+ void afterAll_WhenListeningNotRequired_ShouldSkipProcessing() {
+ when(propertyProvider.getProperty(API_KEY_PROPERTY_NAME)).thenReturn(null);
+
+ listener.afterAll(context);
+
+ verify(runManager, never()).decrementSuiteCounter();
+ verifyNoInteractions(methodExportManager);
}
- @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")).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());
- }
+ @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);
}
- @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")).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")).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")).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);
- }
+ @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());
}
- @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")).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")).thenReturn(null);
-
- // When
- junitListener.testSuccessful(mockExtensionContext);
-
- // Then
- verify(mockReporter, never()).reportTestResult(any(), anyString(), anyString());
- verify(mockMethodExportManager, never()).loadTestBodyForClass(any());
- }
+ @Test
+ void beforeEach_ShouldNotPerformAnyActions() {
+ assertDoesNotThrow(() -> listener.beforeEach(context));
+ verifyNoInteractions(runManager, reporter, methodExportManager, awsService);
}
- @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")).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")).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")).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);
- }
+ @Test
+ void testDisabled_WhenListeningNotRequired_ShouldSkipReporting() {
+ when(propertyProvider.getProperty(API_KEY_PROPERTY_NAME)).thenReturn(null);
+
+ listener.testDisabled(context, Optional.of("Test disabled"));
+
+ verifyNoInteractions(reporter, methodExportManager);
}
- @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")).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")).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")).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);
- }
+ @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);
}
- @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")).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")).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")).thenReturn("true");
- when(mockExtensionContext.getTestClass()).thenReturn(Optional.empty());
-
- // When
- junitListener.testSuccessful(mockExtensionContext);
-
- // Then
- verify(mockMethodExportManager, never()).loadTestBodyForClass(any());
- }
+ @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);
}
- @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"))
- .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"))
- .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"))
- .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);
- }
+ @Test
+ void testSuccessful_WhenListeningNotRequired_ShouldSkipReporting() {
+ when(propertyProvider.getProperty(API_KEY_PROPERTY_NAME)).thenReturn(null);
+
+ listener.testSuccessful(context);
+
+ verifyNoInteractions(reporter, methodExportManager);
}
- @Nested
- @DisplayName("Integration Tests")
- class IntegrationTests {
-
- @Test
- @DisplayName("Should handle complete test lifecycle when listening is required")
- void shouldHandleCompleteTestLifecycleWhenListeningIsRequired() {
- // Given
- when(mockPropertyProvider.getProperty("testomatio")).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")).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")).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);
- }
+ @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
diff --git a/java-reporter-testng/pom.xml b/java-reporter-testng/pom.xml
index bf8b68c..7fdca92 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
+ io.testomat
java-reporter-testng
- test
+ 0.7.9
jar
Testomat.io Java Reporter TestNG
@@ -46,7 +46,7 @@
io.testomat
java-reporter-core
- 0.7.5
+ 0.7.9
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..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
@@ -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.getName(),
+ 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;
+ }
}