Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 57 additions & 17 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -382,19 +382,21 @@ Artifacts are stored in external S3 buckets. S3 Access can be configured in **tw

> 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` |
| `testomatio.step.artifacts.enabled` | Enables uploading artifacts for test steps | `false` |
| `s3.force-path-style` | Use path-style URLs for S3-compatible storage | `false` |
| `s3.endpoint` | Custom endpoint to be used with force-path-style | `false` |
| `s3.bucket` | Provides bucket name for configuration | |
| `s3.access-key-id` | Access key for the bucket | |
| `s3.secret.access-key-id` | Secret access key for the bucket | |
| `s3.region` | Bucket region | `us-west-1` |
| `s3.assume.role.arn` | AWS IAM role ARN used for AssumeRole authentication | |
| `s3.assume.role.external.id` | External ID for AssumeRole authentication | |
| Setting | Description | Default |
|-------------------------------------|-----------------------------------------------------|-------------------------------------------|
| `testomatio.artifact.disable` | Completely disable artifact uploading | `false` |
| `testomatio.artifact.private` | Keep artifacts private (no public URLs) | `false` |
| `testomatio.step.artifacts.enabled` | Enables uploading artifacts for test steps | `false` |
| `s3.force-path-style` | Use path-style URLs for S3-compatible storage | `false` |
| `s3.endpoint` | Custom endpoint to be used with force-path-style | `false` |
| `s3.bucket` | Provides bucket name for configuration | |
| `s3.access-key-id` | Access key for the bucket | |
| `s3.secret.access-key-id` | Secret access key for the bucket | |
| `s3.region` | Bucket region | `us-west-1` |
| `s3.assume.role.arn` | AWS IAM role ARN used for AssumeRole authentication | |
| `s3.assume.role.external.id` | External ID for AssumeRole authentication | |
| `testomatio.artifact.json.path` | Custom path to the JSONL export file | `target/testomat/testomat-artifacts.json` |
| `testomatio.artifact.json.disable` | Disable JSONL artifact export | `false` |

**Note**: S3 credentials can be configured either in properties file or provided automatically on Testomat.io UI.
Environment variables take precedence over server-provided credentials.
Expand Down Expand Up @@ -506,6 +508,18 @@ As a result you will see something like this in the UI after the run is complete

---

## Artifact Export

In addition to uploading artifacts directly to Testomat.io the reporter automatically exports artifact metadata to a JSONL file.

The generated file can later be uploaded using the Testomat CLI:

```bash
npx @testomatio/reporter upload-artifacts <json_path>
```

---

## Step-by-Step Reporting

Track detailed test execution flow using the `@Step` annotation.
Expand Down Expand Up @@ -834,10 +848,36 @@ And the dashboard something like this:

## Advanced Customization

There are void hooks in the listeners that allow you to customize reporting much more.
These hooks are located in the listeners' tests lifecycle methods according to their names.
External API calls, logging and any custom logic can be added to the hooks.
The hooks are executed **after** the lifecycle method logic finishes and do not replace it.
The TestomatHook interface allows you to customize reporting by implementing hooks for the listeners' lifecycle methods.
Each hook corresponds to a specific test lifecycle event. Override only the methods you need—all hook methods have default empty implementations.
External API calls, logging, artifact uploads and any other custom logic can be added to the hooks.
Hooks with the *BeforeExecution suffix are executed before the listener's default logic, while hooks with the *AfterExecution suffix are executed after it. They extend the default behavior and do not replace it.

Example:

```java
import io.testomat.testng.listener.TestomatHook;
import org.testng.ITestResult;

public class CustomHook implements TestomatHook {

@Override
public void onTestFailureHookBeforeExecution(ITestResult result) {
// Your custom logic
}
}
```

Register your implementation via Java ServiceLoader by creating the following file in the resources directory:
```
META-INF/services/io.testomat.junit.listener.TestomatHook
```

with the fully qualified name of your implementation:

```properties
com.yourcompany.yourproject.CustomListener
```

### JUnit, TestNG

Expand Down
2 changes: 1 addition & 1 deletion java-reporter-core/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@

<groupId>io.testomat</groupId>
<artifactId>java-reporter-core</artifactId>
<version>0.14.0</version>
<version>0.15.0</version>
<packaging>jar</packaging>

<name>Testomat.io Reporter Core</name>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,15 @@
import static io.testomat.core.constants.PropertyValuesConstants.DEFAULT_FLUSH_INTERVAL_SECONDS;

import io.testomat.core.client.ApiInterface;
import io.testomat.core.facade.methods.artifact.model.AddTestsBatchRequest;
import io.testomat.core.facade.methods.artifact.model.Step;
import io.testomat.core.facade.methods.artifact.model.TestItem;
import io.testomat.core.model.TestResult;
import java.io.IOException;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
Expand Down Expand Up @@ -35,6 +40,9 @@ public class BatchResultManager {
private final ScheduledExecutorService scheduler;
private final AtomicBoolean isActive = new AtomicBoolean(true);

private final Map<String, TestItem> testItems = new LinkedHashMap<>();
private final Object testItemsLock = new Object();

/**
* Creates batch result manager with configurable settings.
* Initializes batch size and flush interval from properties with fallback to defaults.
Expand Down Expand Up @@ -83,6 +91,34 @@ public synchronized void addResult(TestResult result) {
}
}

public void addTestItem(TestItem testItem) {
synchronized (testItemsLock) {
testItems.put(testItem.getRid(), testItem);
}
}

public AddTestsBatchRequest buildRequest() {
synchronized (testItemsLock) {
return AddTestsBatchRequest.builder(runUid, "addTestsBatch")
.addTests(new ArrayList<>(testItems.values()))
.build();
}
}

public void updateTestSteps(String rid, List<Step> steps) {
synchronized (testItemsLock) {
TestItem item = testItems.get(rid);

if (item == null) {
log.warn("TestItem not found for rid={}", rid);
return;
}

item.setSteps(steps);
log.info("Updated steps for {}", rid);
}
}

/**
* Immediately flushes all pending test results to API.
* Thread-safe operation that can be called concurrently.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@ public interface ApiInterface {

void sendTestWithArtifacts(String uid);

void writeArtifactsToJsonl(String uid);

/**
* Finalizes the test run and marks it as completed.
*
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package io.testomat.core.client;

import static io.testomat.core.constants.ArtifactPropertyNames.JSONL_PATH_PROPERTY_NAME;
import static io.testomat.core.constants.CommonConstants.RESPONSE_UID_KEY;

import io.testomat.core.InfoDisplay;
Expand All @@ -17,8 +18,16 @@
import io.testomat.core.exception.FinishReportFailedException;
import io.testomat.core.exception.ReportingFailedException;
import io.testomat.core.exception.RunCreationFailedException;
import io.testomat.core.facade.methods.artifact.model.AddTestsBatchRequest;
import io.testomat.core.model.TestResult;
import io.testomat.core.propertyconfig.impl.PropertyProviderFactoryImpl;
import io.testomat.core.propertyconfig.interf.PropertyProvider;
import io.testomat.core.runmanager.GlobalRunManager;
import java.io.FileWriter;
import java.io.IOException;
import java.io.PrintWriter;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import java.util.Map;
import org.slf4j.Logger;
Expand All @@ -40,6 +49,7 @@ public class TestomatioClient implements ApiInterface {
private final CredentialsManager credentialsManager = new CredentialsManager();
private final LinkUploadBodyBuilder linkUploadBodyBuilder = new LinkUploadBodyBuilder();
private final CredentialsValidationService credentialsValidationService = new CredentialsValidationService();
private final PropertyProvider provider;

/**
* Creates API client with injectable dependencies.
Expand All @@ -55,6 +65,8 @@ public TestomatioClient(String apiKey,
this.apiKey = apiKey;
this.client = client;
this.requestBodyBuilder = requestBodyBuilder;
this.provider =
PropertyProviderFactoryImpl.getPropertyProviderFactory().getPropertyProvider();
}

@Override
Expand Down Expand Up @@ -132,6 +144,35 @@ public void sendTestWithArtifacts(String uid) {
}
}

@Override
public void writeArtifactsToJsonl(String uid) {
AddTestsBatchRequest request =
GlobalRunManager.getInstance().buildBatchRequest();
if (request == null || request.getTests() == null || request.getTests().isEmpty()) {
return;
}
String filePath = provider.getProperty(JSONL_PATH_PROPERTY_NAME);
Path path = Path.of(filePath);
try {
Files.createDirectories(path.getParent());
try (PrintWriter writer = new PrintWriter(Files.newBufferedWriter(path))) {
writer.println(toJson(request));
log.debug("Wrote {} entries to {}", request.getTests().size(), path);
}
} catch (IOException e) {
log.error("Failed to write JSON file {}", path, e);
}
}

private String toJson(Object value) {
try {
return new com.fasterxml.jackson.databind.ObjectMapper().writeValueAsString(value);
} catch (com.fasterxml.jackson.core.JsonProcessingException e) {
log.error("Failed to serialize JSON", e);
return "{}";
}
}

@Override
public void finishTestRun(String uid, float duration) {
try {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,22 +15,27 @@
import io.testomat.core.exception.FailedToCreateRunBodyException;
import io.testomat.core.facade.methods.artifact.ReportedTestStorage;
import io.testomat.core.facade.methods.artifact.TempArtifactDirectoriesStorage;
import io.testomat.core.facade.methods.artifact.model.Step;
import io.testomat.core.facade.methods.label.LabelStorage;
import io.testomat.core.facade.methods.logmethod.LogStorage;
import io.testomat.core.facade.methods.meta.MetaStorage;
import io.testomat.core.model.Link;
import io.testomat.core.model.TestResult;
import io.testomat.core.propertyconfig.impl.PropertyProviderFactoryImpl;
import io.testomat.core.propertyconfig.interf.PropertyProvider;
import io.testomat.core.runmanager.GlobalRunManager;
import io.testomat.core.step.StepData;
import io.testomat.core.step.TestStep;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
import java.util.stream.Collectors;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
Expand Down Expand Up @@ -175,7 +180,15 @@ private Map<String, Object> buildTestResultMap(TestResult result) throws JsonPro
result.getSteps().forEach(this::processStepArtifacts);
List<Map<String, Object>> stepsMap = convertStepsToMap(result.getSteps());
body.put("steps", stepsMap);
log.debug("Adding {} steps to request body for test: {}", result.getSteps().size(), result.getTitle());

GlobalRunManager.getInstance().updateTestSteps(
result.getRid(),
convertToJsonlSteps(result.getSteps())
);

log.debug("Adding {} steps to request body for test: {}",
result.getSteps().size(),
result.getTitle());
}

if (createParam) {
Expand Down Expand Up @@ -394,16 +407,54 @@ private void addLinks(TestResult result, String rid) {
* @param step step to process
*/
private void processStepArtifacts(TestStep step) {
List<String> links =
TempArtifactDirectoriesStorage.STEP_DIRECTORIES
.remove(step.getId());
StepData stepData = null;

for (Map<UUID, StepData> steps : TempArtifactDirectoriesStorage.STEP_DATA.values()) {
stepData = steps.get(step.getId());
if (stepData != null) {
break;
}
}

if (links != null && !links.isEmpty()) {
step.setArtifacts(links.toArray(new String[0]));
if (stepData != null && !stepData.getArtifacts().isEmpty()) {
step.setArtifacts(stepData.getArtifacts().toArray(new String[0]));
}

if (step.getSubsteps() != null && !step.getSubsteps().isEmpty()) {
step.getSubsteps().forEach(this::processStepArtifacts);
}
}

private List<Step> convertToJsonlSteps(List<TestStep> steps) {
return steps.stream()
.map(this::convertToJsonlStep)
.collect(Collectors.toList());
}

private Step convertToJsonlStep(TestStep step) {
StepData stepData = null;

for (Map<UUID, StepData> steps : TempArtifactDirectoriesStorage.STEP_DATA.values()) {
stepData = steps.remove(step.getId());
if (stepData != null) {
break;
}
}

List<Step> substeps = null;
if (step.getSubsteps() != null && !step.getSubsteps().isEmpty()) {
substeps = convertToJsonlSteps(step.getSubsteps());
}

return new Step(
step.getStepTitle(),
step.getStatus(),
step.getLog(),
step.getError(),
step.getDuration(),
step.getCategory(),
stepData != null ? stepData.getDirectories() : null,
substeps
);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,4 +16,7 @@ public class ArtifactPropertyNames {
public static final String MAX_SIZE_ARTIFACTS_PROPERTY_NAME = "testomatio.artifact.max-size";

public static final String STEP_ARTIFACT_ENABLED_PROPERTY_NAME = "testomatio.step.artifacts.enabled";

public static final String JSONL_EXPORT_PROPERTY_NAME = "testomatio.artifact.json.disable";
public static final String JSONL_PATH_PROPERTY_NAME = "testomatio.artifact.json.path";
}
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
package io.testomat.core.constants;

public class CommonConstants {
public static final String REPORTER_VERSION = "0.14.0";
public static final String REPORTER_VERSION = "0.15.0";

public static final String TESTS_STRING = "tests";
public static final String API_KEY_STRING = "api_key";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,5 @@ public class PropertyValuesConstants {
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";
public static final String DEFAULT_JSONL_PATH = "target/testomat/testomat-artifacts.json";
}
Loading
Loading