org.apache.maven.plugins
@@ -509,7 +611,7 @@ To enable named placeholders, add to `pom.xml`:
* def stepMarker = Java.type('io.testomat.karate.marker.StepMarker')
* def step = stepMarker.mark
```
-After this, step() can be used as a regular Karate function.
+After this step() can be used as a regular Karate function.
#### Usage without a title
```gherkin
* step()
@@ -524,7 +626,7 @@ Log example: ```path 'posts'```
Log example: ``` Send get request```
#### Logging all steps with @LogSteps
-If a scenario is annotated with the @LogSteps tag, all Karate steps in that scenario will be logged automatically.
+If a scenario is annotated with the @LogSteps tag all Karate steps in that scenario will be logged automatically.
#### Example
```gherkin
@@ -584,6 +686,8 @@ If executed inside another step (including methods annotated with `@Step`) a sub
Artifacts can be attached to a step using the `artifacts` attribute of the `@Step` annotation.
+Methods annotated with `@Artifact` also support step-level attachment when executed inside a step.
+
```java
@Step(value = "Login", artifacts = {"path_to_artifact1", "path_to_artifact2"})
public void login() {
@@ -599,9 +703,9 @@ You can also attach artifacts to a step programmatically:
Testomatio.stepArtifact("path_to_attachment1", "path_to_attachment2");
```
-If called inside a step, the artifacts will be attached to the current step.
+If called inside a step the artifacts will be attached to the current step.
-If called after a step finishes, it will be attached to the last completed step.
+If called after a step finishes it will be attached to the last completed step.
### What You'll See
@@ -614,13 +718,13 @@ This provides complete transparency into test flow and helps debug failures quic
---
-## 🎯 Test Filtering by ID
+## Test Filtering by ID
**JUnit & TestNG only**
> **Note**:
> Cucumber tests can be filtered using native Cucumber tags functionality (`@tag` in feature files and `cucumber.filter.tags` property).
->
Karate supports tagging of features and scenarios using the standard Gherkin tag syntax (@tag). Tags allow you to organize, group, and selectively run tests.
+>
Karate supports tagging of features and scenarios using the standard Gherkin tag syntax (@tag). Tags allow you to organize, group and selectively run tests.
Run specific tests by their `@TestId` values using the `-Dids` parameter. This is useful for:
- Running smoke tests or critical path tests
@@ -678,7 +782,7 @@ public class LoginTests {
---
-## 💡 Library Usage Examples
+## Common Usage Scenarios
### Basic Usage
@@ -711,40 +815,40 @@ mvn test \
---
-## 📊 What You'll See
+## What You'll See
-When your tests start running, you'll see helpful output like this:
+When your tests start running you'll see helpful output like this:

**You get two types of links:**
-- **🔒 Private Link**: Full access on Testomat.io platform (for your team)
-- **🌐 Public Link**: Shareable read-only view (only if you set `testomatio.publish=1`)
+- **Private Link**: Full access on Testomat.io platform (for your team)
+- **Public Link**: Shareable read only view (only if you set `testomatio.publish=1`)
-And the dashboard - something like this:
+And the dashboard something like this:

---
-## Advanced customization
+## 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.
+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.
### JUnit, TestNG
1. Complete the Simple Setup first
-2. Create a new class that extends JunitListener or TestNgListener, based on your needs.
+2. Create a new class that extends JunitListener or TestNgListener based on your needs.
Implement protected methods from the library listener and add custom logic to them.
3. Create the `services` directory:
```
- 📁 src/main/resources/META-INF/services/
+ src/main/resources/META-INF/services/
```
4. Create the right configuration file:
@@ -804,7 +908,7 @@ The hooks are executed **after** the lifecycle method logic finishes and do not
Testomat Allure Reporter is a Java integration library that bridges Allure reporting with Testomat.io test management system.
-The library automatically captures test metadata, titles, steps, and attachments from Allure and sends them to Testomat.io, providing seamless synchronization between test execution and test management.
+The library automatically captures test metadata, titles, steps and attachments from Allure and sends them to Testomat.io providing seamless synchronization between test execution and test management.
Key features:
- Automatic test title synchronization
@@ -813,7 +917,7 @@ Key features:
- JUnit and TestNG integration
- Minimal configuration required
-To enable Testomat Allure integration, add the following dependency:
+To enable Testomat Allure integration add the following dependency:
```xml
@@ -839,7 +943,7 @@ To enable Testomat Allure integration, add the following dependency:
```
-## 🆘 Troubleshooting
+## Troubleshooting
### Tests not appearing in Testomat.io?
@@ -852,7 +956,7 @@ To enable Testomat Allure integration, add the following dependency:
1. **JUnit 5**: Make sure `junit-platform.properties` exists with autodetection enabled
2. **Cucumber**: Verify the listener is in your `@CucumberOptions` plugins
3. **TestNG**: Should work automatically if nothing is overridden - check your TestNG version (need 7.x)
-4. **Karate**: **Karate**: Verify that the KarateHookFactory is installed `.hookFactory(KarateHookFactory.create())`
+4. **Karate**: Verify that the KarateHookFactory is installed `.hookFactory(KarateHookFactory.create())`
---
@@ -860,4 +964,13 @@ To enable Testomat Allure integration, add the following dependency:
1. Create an issue. We'll fix it!
-> 💝 **Love this tool?** Star the repo and share with your team!
\ No newline at end of file
+## Support
+
+If you find this project useful:
+
+- Star the repository
+- Report issues
+- Suggest improvements
+- Share feedback
+
+We appreciate your support.
\ No newline at end of file
diff --git a/java-reporter-core/pom.xml b/java-reporter-core/pom.xml
index 94f94df..97da840 100644
--- a/java-reporter-core/pom.xml
+++ b/java-reporter-core/pom.xml
@@ -7,7 +7,7 @@
io.testomat
java-reporter-core
- 0.13.1
+ 0.14.0
jar
Testomat.io Reporter Core
@@ -104,6 +104,12 @@
5.8.0
test
+
+ commons-io
+ commons-io
+ 2.22.0
+ compile
+
diff --git a/java-reporter-core/src/main/java/io/testomat/core/annotation/Artifact.java b/java-reporter-core/src/main/java/io/testomat/core/annotation/Artifact.java
new file mode 100644
index 0000000..c2d8298
--- /dev/null
+++ b/java-reporter-core/src/main/java/io/testomat/core/annotation/Artifact.java
@@ -0,0 +1,11 @@
+package io.testomat.core.annotation;
+
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+
+@Retention(RetentionPolicy.RUNTIME)
+@Target(ElementType.METHOD)
+public @interface Artifact {
+}
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 2cb21ef..9826cc7 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.13.1";
+ public static final String REPORTER_VERSION = "0.14.0";
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/facade/Testomatio.java b/java-reporter-core/src/main/java/io/testomat/core/facade/Testomatio.java
index c970821..29d6f94 100644
--- a/java-reporter-core/src/main/java/io/testomat/core/facade/Testomatio.java
+++ b/java-reporter-core/src/main/java/io/testomat/core/facade/Testomatio.java
@@ -16,7 +16,6 @@
import java.io.StringWriter;
import java.util.List;
import java.util.Map;
-import java.util.Optional;
/**
* Main public API facade for Testomat.io integration.
@@ -45,10 +44,6 @@ public static void stepArtifact(String... directories) {
}
TestStep testStep = StepLifecycle.current();
- if (directories == null || directories.length == 0){
- return;
- }
-
if (testStep == null) {
testStep = StepLifecycle.lastFinished();
}
@@ -81,8 +76,9 @@ public static void step(String stepName, Runnable action) {
step.setStatus(StepStatus.failed);
step.setLog(getStackTrace(t));
step.setError(
- Optional.ofNullable(t.getMessage())
- .orElse(t.getClass().getSimpleName())
+ t.getMessage() == null || t.getMessage().isBlank()
+ ? t.getClass().getSimpleName()
+ : t.getClass().getSimpleName() + ": " + t.getMessage()
);
throwUnchecked(t);
} finally {
diff --git a/java-reporter-core/src/main/java/io/testomat/core/facade/methods/artifact/ArtifactAspect.java b/java-reporter-core/src/main/java/io/testomat/core/facade/methods/artifact/ArtifactAspect.java
new file mode 100644
index 0000000..3c23f81
--- /dev/null
+++ b/java-reporter-core/src/main/java/io/testomat/core/facade/methods/artifact/ArtifactAspect.java
@@ -0,0 +1,57 @@
+package io.testomat.core.facade.methods.artifact;
+
+import io.testomat.core.facade.Testomatio;
+import io.testomat.core.step.StepLifecycle;
+import io.testomat.core.step.TestStep;
+import java.io.File;
+import java.nio.file.Path;
+import org.aspectj.lang.annotation.AfterReturning;
+import org.aspectj.lang.annotation.Aspect;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+@Aspect
+public class ArtifactAspect {
+ private static final Logger log = LoggerFactory.getLogger(ArtifactAspect.class);
+
+ @AfterReturning(
+ pointcut = "@annotation(io.testomat.core.annotation.Artifact)",
+ returning = "result"
+ )
+ public void afterArtifact(Object result) {
+ String fileName = resolveFileName(result);
+ if (fileName == null) {
+ return;
+ }
+
+ TestStep testStep = StepLifecycle.current();
+ if (testStep == null) {
+ testStep = StepLifecycle.lastFinished();
+ }
+ if (testStep == null || testStep.getId() == null) {
+ Testomatio.artifact(fileName);
+ } else {
+ Testomatio.stepArtifact(fileName);
+ }
+ }
+
+ private String resolveFileName(Object result) {
+ if (result instanceof String) {
+ return (String) result;
+ }
+ if (result instanceof Path) {
+ return ((Path) result).toString();
+ }
+ if (result instanceof File) {
+ return ((File) result).getAbsolutePath();
+ }
+
+ log.warn(
+ "@Artifact ignored: method returned unsupported type '{}'. "
+ + "Supported types are String, Path and File.",
+ result == null ? "null" : result.getClass().getName()
+ );
+
+ return null;
+ }
+}
diff --git a/java-reporter-core/src/main/java/io/testomat/core/facade/methods/artifact/manager/ArtifactManager.java b/java-reporter-core/src/main/java/io/testomat/core/facade/methods/artifact/manager/ArtifactManager.java
index 5019a8f..008a413 100644
--- a/java-reporter-core/src/main/java/io/testomat/core/facade/methods/artifact/manager/ArtifactManager.java
+++ b/java-reporter-core/src/main/java/io/testomat/core/facade/methods/artifact/manager/ArtifactManager.java
@@ -32,6 +32,9 @@ public void storeStepDirectories(UUID stepId, String... directories) {
}
private void store(String[] directories, Consumer storage, String logMessage) {
+ if (directories == null) {
+ return;
+ }
for (String dir : directories) {
if (!isValidFilePath(dir)) {
diff --git a/java-reporter-core/src/main/java/io/testomat/core/step/StepAspect.java b/java-reporter-core/src/main/java/io/testomat/core/step/StepAspect.java
index 1332944..82979c2 100644
--- a/java-reporter-core/src/main/java/io/testomat/core/step/StepAspect.java
+++ b/java-reporter-core/src/main/java/io/testomat/core/step/StepAspect.java
@@ -3,8 +3,9 @@
import static io.testomat.core.facade.Testomatio.stepArtifact;
import io.testomat.core.annotation.Step;
+import java.io.PrintWriter;
+import java.io.StringWriter;
import java.lang.reflect.Method;
-import java.util.Arrays;
import org.aspectj.lang.JoinPoint;
import org.aspectj.lang.annotation.*;
import org.aspectj.lang.annotation.Aspect;
@@ -95,10 +96,10 @@ public void afterSuccess(JoinPoint joinPoint) {
* and completes the step lifecycle.
*
* @param joinPoint intercepted method invocation
- * @param e thrown exception
+ * @param t thrown exception
*/
- @AfterThrowing(pointcut = "stepAnnotation()", throwing = "e")
- public void afterFailure(JoinPoint joinPoint, Throwable e) {
+ @AfterThrowing(pointcut = "stepAnnotation()", throwing = "t")
+ public void afterFailure(JoinPoint joinPoint, Throwable t) {
Step step = resolveStepAnnotation(joinPoint);
TestStep testStep = StepLifecycle.current();
@@ -113,20 +114,24 @@ public void afterFailure(JoinPoint joinPoint, Throwable e) {
testStep.setStatus(StepStatus.failed);
testStep.setDuration(duration);
- testStep.setError(e.getMessage());
- testStep.setLog(Arrays.toString(e.getStackTrace()));
+ testStep.setError(
+ t.getMessage() == null || t.getMessage().isBlank()
+ ? t.getClass().getSimpleName()
+ : t.getClass().getSimpleName() + ": " + t.getMessage()
+ );
+ testStep.setLog(getStackTrace(t));
if (artifacts != null) {
stepArtifact(artifacts);
}
- log.debug("Step '{}' failed in {} ms", stepName, duration, e);
+ log.debug("Step '{}' failed in {} ms", stepName, duration, t);
StepLifecycle.finish();
}
private long calculateDuration(String stepId) {
- return System.currentTimeMillis() - StepTimer.stop(stepId);
+ return StepTimer.stop(stepId);
}
private Step resolveStepAnnotation(JoinPoint joinPoint) {
@@ -215,4 +220,10 @@ private String substituteParameters(String stepName, JoinPoint joinPoint) {
private String format(Object value) {
return value == null ? "null" : value.toString();
}
+
+ private static String getStackTrace(Throwable t) {
+ StringWriter sw = new StringWriter();
+ t.printStackTrace(new PrintWriter(sw));
+ return sw.toString();
+ }
}
\ No newline at end of file
diff --git a/java-reporter-core/src/main/resources/META-INF/aop.xml b/java-reporter-core/src/main/resources/META-INF/aop.xml
index 6b93280..1bf6b9c 100644
--- a/java-reporter-core/src/main/resources/META-INF/aop.xml
+++ b/java-reporter-core/src/main/resources/META-INF/aop.xml
@@ -2,6 +2,7 @@
+
diff --git a/java-reporter-core/src/test/java/io/testomat/core/artifact/ArtifactAspectTest.java b/java-reporter-core/src/test/java/io/testomat/core/artifact/ArtifactAspectTest.java
new file mode 100644
index 0000000..dfab981
--- /dev/null
+++ b/java-reporter-core/src/test/java/io/testomat/core/artifact/ArtifactAspectTest.java
@@ -0,0 +1,138 @@
+package io.testomat.core.artifact;
+
+import io.testomat.core.facade.Testomatio;
+import io.testomat.core.facade.methods.artifact.ArtifactAspect;
+import io.testomat.core.step.StepLifecycle;
+import io.testomat.core.step.TestStep;
+import java.io.File;
+import java.nio.file.Path;
+import java.util.UUID;
+import org.junit.jupiter.api.Test;
+import org.mockito.MockedStatic;
+
+import static org.mockito.Mockito.*;
+
+class ArtifactAspectTest {
+
+ private final ArtifactAspect aspect = new ArtifactAspect();
+
+ @Test
+ void shouldSendStringArtifact() {
+ try (MockedStatic lifecycle = mockStatic(StepLifecycle.class);
+ MockedStatic testomatio = mockStatic(Testomatio.class)) {
+
+ lifecycle.when(StepLifecycle::current).thenReturn(null);
+ lifecycle.when(StepLifecycle::lastFinished).thenReturn(null);
+
+ aspect.afterArtifact("file.txt");
+
+ testomatio.verify(() -> Testomatio.artifact("file.txt"));
+ }
+ }
+
+ @Test
+ void shouldSendPathArtifact() {
+ try (MockedStatic lifecycle = mockStatic(StepLifecycle.class);
+ MockedStatic testomatio = mockStatic(Testomatio.class)) {
+
+ lifecycle.when(StepLifecycle::current).thenReturn(null);
+ lifecycle.when(StepLifecycle::lastFinished).thenReturn(null);
+
+ Path path = Path.of("file.txt");
+
+ aspect.afterArtifact(path);
+
+ testomatio.verify(() -> Testomatio.artifact(path.toString()));
+ }
+ }
+
+ @Test
+ void shouldSendFileArtifact() {
+ try (MockedStatic lifecycle = mockStatic(StepLifecycle.class);
+ MockedStatic testomatio = mockStatic(Testomatio.class)) {
+
+ lifecycle.when(StepLifecycle::current).thenReturn(null);
+ lifecycle.when(StepLifecycle::lastFinished).thenReturn(null);
+
+ File file = new File("file.txt");
+
+ aspect.afterArtifact(file);
+
+ testomatio.verify(() -> Testomatio.artifact(file.getAbsolutePath()));
+ }
+ }
+
+ @Test
+ void shouldSendStepArtifactWhenCurrentStepExists() {
+ try (MockedStatic lifecycle = mockStatic(StepLifecycle.class);
+ MockedStatic testomatio = mockStatic(Testomatio.class)) {
+
+ TestStep step = mock(TestStep.class);
+
+ when(step.getId()).thenReturn(UUID.randomUUID());
+
+ lifecycle.when(StepLifecycle::current).thenReturn(step);
+
+ aspect.afterArtifact("file.txt");
+
+ testomatio.verify(() -> Testomatio.stepArtifact("file.txt"));
+ }
+ }
+
+ @Test
+ void shouldSendStepArtifactWhenLastFinishedStepExists() {
+ try (MockedStatic lifecycle = mockStatic(StepLifecycle.class);
+ MockedStatic testomatio = mockStatic(Testomatio.class)) {
+
+ TestStep step = mock(TestStep.class);
+
+ when(step.getId()).thenReturn(UUID.randomUUID());
+
+ lifecycle.when(StepLifecycle::current).thenReturn(null);
+ lifecycle.when(StepLifecycle::lastFinished).thenReturn(step);
+
+ aspect.afterArtifact("file.txt");
+
+ testomatio.verify(() -> Testomatio.stepArtifact("file.txt"));
+ }
+ }
+
+ @Test
+ void shouldSendArtifactWhenStepHasNullId() {
+ try (MockedStatic lifecycle = mockStatic(StepLifecycle.class);
+ MockedStatic testomatio = mockStatic(Testomatio.class)) {
+
+ TestStep step = mock(TestStep.class);
+
+ when(step.getId()).thenReturn(null);
+
+ lifecycle.when(StepLifecycle::current).thenReturn(step);
+
+ aspect.afterArtifact("file.txt");
+
+ testomatio.verify(() -> Testomatio.artifact("file.txt"));
+ }
+ }
+
+ @Test
+ void shouldIgnoreUnsupportedType() {
+ try (MockedStatic lifecycle = mockStatic(StepLifecycle.class);
+ MockedStatic testomatio = mockStatic(Testomatio.class)) {
+
+ aspect.afterArtifact(new Object());
+
+ testomatio.verifyNoInteractions();
+ }
+ }
+
+ @Test
+ void shouldIgnoreNull() {
+ try (MockedStatic lifecycle = mockStatic(StepLifecycle.class);
+ MockedStatic testomatio = mockStatic(Testomatio.class)) {
+
+ aspect.afterArtifact(null);
+
+ testomatio.verifyNoInteractions();
+ }
+ }
+}
\ No newline at end of file
diff --git a/java-reporter-core/src/test/java/io/testomat/core/step/StepAspectTest.java b/java-reporter-core/src/test/java/io/testomat/core/step/StepAspectTest.java
index 16b6171..6be43ff 100644
--- a/java-reporter-core/src/test/java/io/testomat/core/step/StepAspectTest.java
+++ b/java-reporter-core/src/test/java/io/testomat/core/step/StepAspectTest.java
@@ -215,6 +215,11 @@ private void stepThatThrows() {
throw new RuntimeException("Test exception");
}
+ @Step("Failing step")
+ void stepThatThrowsWithoutMessage() {
+ throw new RuntimeException();
+ }
+
@Step("Process user: {0}")
private void stepWithComplexObject(User user) {
log.info("Processing user: {}", user);
@@ -244,10 +249,17 @@ void testFailureStatus(){
}
@Test
- void testErrorMessageStored(){
+ void testErrorMessageStored() {
assertThrows(RuntimeException.class, this::stepThatThrows);
- TestStep step= StepStorage.getSteps().get(0);
- assertEquals("Test exception", step.getError());
+ TestStep step = StepStorage.getSteps().get(0);
+ assertTrue(step.getError().contains("Test exception"));
+ }
+
+ @Test
+ void shouldStoreExceptionClassWhenMessageIsNull() {
+ assertThrows(RuntimeException.class, this::stepThatThrowsWithoutMessage);
+ TestStep step = StepStorage.getSteps().get(0);
+ assertEquals("RuntimeException", step.getError());
}
@Test
diff --git a/java-reporter-cucumber/pom.xml b/java-reporter-cucumber/pom.xml
index 17601a5..8cca9f0 100644
--- a/java-reporter-cucumber/pom.xml
+++ b/java-reporter-cucumber/pom.xml
@@ -6,7 +6,7 @@
io.testomat
java-reporter-cucumber
- 0.7.17
+ 0.7.18
jar
Testomat.io Java Reporter Cucumber
@@ -51,7 +51,7 @@
io.testomat
java-reporter-core
- 0.13.1
+ 0.14.0
org.slf4j
diff --git a/java-reporter-junit/pom.xml b/java-reporter-junit/pom.xml
index 1a00a3b..3c9ff99 100644
--- a/java-reporter-junit/pom.xml
+++ b/java-reporter-junit/pom.xml
@@ -6,7 +6,7 @@
io.testomat
java-reporter-junit
- 0.8.9
+ 0.8.10
jar
Testomat.io Java Reporter JUnit
@@ -51,7 +51,7 @@
io.testomat
java-reporter-core
- 0.13.1
+ 0.14.0
org.slf4j
diff --git a/java-reporter-karate/pom.xml b/java-reporter-karate/pom.xml
index d603e8f..46e8026 100644
--- a/java-reporter-karate/pom.xml
+++ b/java-reporter-karate/pom.xml
@@ -6,7 +6,7 @@
io.testomat
java-reporter-karate
- 0.2.11
+ 0.2.12
jar
Testomat.io Java Reporter Karate
@@ -52,7 +52,7 @@
io.testomat
java-reporter-core
- 0.13.1
+ 0.14.0
io.karatelabs
diff --git a/java-reporter-testng/pom.xml b/java-reporter-testng/pom.xml
index f47d10b..b25d1f8 100644
--- a/java-reporter-testng/pom.xml
+++ b/java-reporter-testng/pom.xml
@@ -6,7 +6,7 @@
io.testomat
java-reporter-testng
- 0.7.16
+ 0.7.17
jar
Testomat.io Java Reporter TestNG
@@ -47,7 +47,7 @@
io.testomat
java-reporter-core
- 0.13.1
+ 0.14.0
org.slf4j
diff --git a/pom.xml b/pom.xml
index beda0a9..6079a9c 100644
--- a/pom.xml
+++ b/pom.xml
@@ -6,7 +6,7 @@
io.testomat
java-reporter
- 0.3.5
+ 0.3.6
pom
Testomat.io Java Reporter
diff --git a/testomat-allure-adapter/pom.xml b/testomat-allure-adapter/pom.xml
index 1685fc7..81fc96a 100644
--- a/testomat-allure-adapter/pom.xml
+++ b/testomat-allure-adapter/pom.xml
@@ -6,7 +6,7 @@
io.testomat
testomat-allure-adapter
- 0.0.7
+ 0.1.0
jar
Testomat.io Testomat Allure adapter
@@ -43,6 +43,7 @@
7.12.0
3.27.7
2.0.17
+ 3.3.1
3.11.0
3.3.0
@@ -66,7 +67,7 @@
io.testomat
java-reporter-core
- 0.13.1
+ 0.14.0
io.qameta.allure
@@ -126,6 +127,11 @@
slf4j-api
${slf4j.api.version}
+
+ org.apache.tika
+ tika-core
+ ${tika.core.version}
+
diff --git a/testomat-allure-adapter/src/main/java/io/testomat/aspect/AllureAttachmentAspect.java b/testomat-allure-adapter/src/main/java/io/testomat/aspect/AllureAttachmentAspect.java
index ce83c45..89abde2 100644
--- a/testomat-allure-adapter/src/main/java/io/testomat/aspect/AllureAttachmentAspect.java
+++ b/testomat-allure-adapter/src/main/java/io/testomat/aspect/AllureAttachmentAspect.java
@@ -7,9 +7,12 @@
import io.testomat.testomat.TestomatClient;
import io.testomat.testomat.TestomatClientImpl;
import java.io.InputStream;
+import java.nio.file.Files;
+import java.nio.file.Path;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.ConcurrentHashMap;
+import org.apache.tika.mime.MimeTypes;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
@@ -26,7 +29,6 @@ public class AllureAttachmentAspect {
private static final Logger log = LoggerFactory.getLogger(AllureAttachmentAspect.class);
private static final Map attachments = new ConcurrentHashMap<>();
- private static final ThreadLocal userAttachment = ThreadLocal.withInitial(() -> false);
private final AllureClient allure;
private final TestomatClient testomatio;
private final AttachmentFileResolver resolver;
@@ -45,27 +47,12 @@ public AllureAttachmentAspect(AllureClient allure,
}
- /** Marks user attachments created via Allure API. */
- @Around("execution(* io.qameta.allure.Allure.addAttachment(..))")
- public Object interceptUserAttachment(ProceedingJoinPoint joinPoint) throws Throwable {
- userAttachment.set(true);
- try {
- return joinPoint.proceed();
- } finally {
- userAttachment.remove();
- }
- }
-
/** Collects attachment metadata during preparation phase. */
@Around("execution(* io.qameta.allure.AllureLifecycle.prepareAttachment(..))")
public Object interceptPrepare(ProceedingJoinPoint joinPoint) throws Throwable {
Object result = joinPoint.proceed();
String uuid = (String) result;
- if (!userAttachment.get()) {
- return result;
- }
-
AttachmentMeta meta = attachments.computeIfAbsent(uuid, k -> new AttachmentMeta());
Object[] args = joinPoint.getArgs();
@@ -129,10 +116,11 @@ private Nodes resolveLevel(AttachmentMeta meta) {
/** Sends attachment to Testomat. */
private void sendToTestomat(AttachmentMeta meta) {
+ String filePath = addExtension(meta.path, meta.type);
if (meta.level.equals(Nodes.step.name())) {
- testomatio.stepArtifact(meta.path);
+ testomatio.stepArtifact(filePath);
} else if (meta.level.equals(Nodes.test.name())) {
- testomatio.artifact(meta.path);
+ testomatio.artifact(filePath);
}
log.debug("===== TESTOMAT ATTACHMENT =====");
@@ -147,6 +135,41 @@ private void sendToTestomat(AttachmentMeta meta) {
log.debug("==============================");
}
+ private String addExtension(String fileName, String mimeType) {
+ try {
+ String extension = MimeTypes.getDefaultMimeTypes()
+ .forName(mimeType)
+ .getExtension();
+
+ if (fileName.endsWith(extension)) {
+ return fileName;
+ }
+
+ Path source = Path.of(fileName);
+ Path target = Path.of(fileName + extension);
+
+ if (!Files.exists(source)) {
+ log.debug("Attachment file not found: {}", source);
+ return fileName;
+ }
+
+ if (Files.exists(target)) {
+ log.debug("Attachment copy already exists: {}", target);
+ return target.toString();
+ }
+
+ Files.copy(source, target);
+
+ log.debug("Created attachment copy with extension: {} -> {}", source, target);
+
+ return target.toString();
+
+ } catch (Exception e) {
+ log.debug("Failed to add extension '{}' to attachment '{}'", mimeType, fileName, e);
+ return fileName;
+ }
+ }
+
static class AttachmentMeta {
private String uuid;
private String testUuid;
diff --git a/testomat-allure-adapter/src/main/java/io/testomat/aspect/AllureTmsLinkAspect.java b/testomat-allure-adapter/src/main/java/io/testomat/aspect/AllureTmsLinkAspect.java
new file mode 100644
index 0000000..602fb30
--- /dev/null
+++ b/testomat-allure-adapter/src/main/java/io/testomat/aspect/AllureTmsLinkAspect.java
@@ -0,0 +1,32 @@
+package io.testomat.aspect;
+
+import io.testomat.resolver.AllureTmsResolver;
+import java.lang.reflect.Method;
+import org.aspectj.lang.ProceedingJoinPoint;
+import org.aspectj.lang.annotation.Around;
+import org.aspectj.lang.annotation.Aspect;
+
+@Aspect
+public class AllureTmsLinkAspect {
+
+ private final AllureTmsResolver resolver;
+
+ public AllureTmsLinkAspect() {
+ this(new AllureTmsResolver());
+ }
+
+ public AllureTmsLinkAspect(AllureTmsResolver resolver) {
+ this.resolver = resolver;
+ }
+
+ @Around("execution(String io.testomat.*.extractor..*.*TestId(java.lang.reflect.Method))")
+ public Object intercept(ProceedingJoinPoint pjp) throws Throwable {
+ String result = (String) pjp.proceed();
+ if (result != null && !result.isBlank()) {
+ return result;
+ }
+ Method method = (Method) pjp.getArgs()[0];
+ String tmsId = resolver.resolve(method);
+ return tmsId != null ? tmsId : result;
+ }
+}
diff --git a/testomat-allure-adapter/src/main/java/io/testomat/resolver/AllureTmsResolver.java b/testomat-allure-adapter/src/main/java/io/testomat/resolver/AllureTmsResolver.java
new file mode 100644
index 0000000..537ae8a
--- /dev/null
+++ b/testomat-allure-adapter/src/main/java/io/testomat/resolver/AllureTmsResolver.java
@@ -0,0 +1,16 @@
+package io.testomat.resolver;
+
+import io.qameta.allure.TmsLink;
+import java.lang.reflect.Method;
+
+public class AllureTmsResolver implements TestMetadataResolver {
+
+ @Override
+ public String resolve(Method method) {
+ TmsLink tmsLink = method.getAnnotation(TmsLink.class);
+ if (tmsLink != null) {
+ return tmsLink.value();
+ }
+ return null;
+ }
+}
diff --git a/testomat-allure-adapter/src/main/resources/META-INF/aop.xml b/testomat-allure-adapter/src/main/resources/META-INF/aop.xml
index 941936f..57783b4 100644
--- a/testomat-allure-adapter/src/main/resources/META-INF/aop.xml
+++ b/testomat-allure-adapter/src/main/resources/META-INF/aop.xml
@@ -3,6 +3,7 @@
+
diff --git a/testomat-allure-adapter/src/test/java/aspect/AllureAttachmentAspectTest.java b/testomat-allure-adapter/src/test/java/aspect/AllureAttachmentAspectTest.java
index 1619dcf..ddccad6 100644
--- a/testomat-allure-adapter/src/test/java/aspect/AllureAttachmentAspectTest.java
+++ b/testomat-allure-adapter/src/test/java/aspect/AllureAttachmentAspectTest.java
@@ -4,6 +4,9 @@
import io.testomat.aspect.AllureAttachmentAspect;
import io.testomat.resolver.AttachmentFileResolver;
import io.testomat.testomat.TestomatClient;
+import java.lang.reflect.Method;
+import java.nio.file.Files;
+import java.nio.file.Path;
import org.aspectj.lang.ProceedingJoinPoint;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
@@ -36,22 +39,6 @@ void clearStaticState() throws Exception {
Field attachments = AllureAttachmentAspect.class.getDeclaredField("attachments");
attachments.setAccessible(true);
((Map, ?>) attachments.get(null)).clear();
-
- Field threadLocal = AllureAttachmentAspect.class.getDeclaredField("userAttachment");
- threadLocal.setAccessible(true);
- ThreadLocal> tl = (ThreadLocal>) threadLocal.get(null);
- tl.remove();
- }
-
- @Test
- void shouldInterceptUserAttachment() throws Throwable {
- ProceedingJoinPoint joinPoint = mock(ProceedingJoinPoint.class);
-
- when(joinPoint.proceed()).thenReturn("ok");
- Object result = aspect.interceptUserAttachment(joinPoint);
-
- assertThat(result).isEqualTo("ok");
- verify(joinPoint).proceed();
}
@Test
@@ -63,7 +50,6 @@ void shouldCreateTestLevelMeta() throws Throwable {
when(joinPoint.getArgs()).thenReturn(new Object[]{"file", "text/plain"});
when(joinPoint.proceed()).thenReturn("uuid");
- aspect.interceptUserAttachment(joinPoint);
Object result = aspect.interceptPrepare(joinPoint);
assertThat(result).isEqualTo("uuid");
@@ -78,7 +64,6 @@ void shouldCreateStepLevelMeta() throws Throwable {
when(joinPoint.getArgs()).thenReturn(new Object[]{"file", "text/plain"});
when(joinPoint.proceed()).thenReturn("uuid");
- aspect.interceptUserAttachment(joinPoint);
aspect.interceptPrepare(joinPoint);
}
@@ -91,18 +76,12 @@ void shouldCreateFixtureLevelMeta() throws Throwable {
when(joinPoint.getArgs()).thenReturn(new Object[]{"file", "text/plain"});
when(joinPoint.proceed()).thenReturn("uuid");
- aspect.interceptUserAttachment(joinPoint);
aspect.interceptPrepare(joinPoint);
}
@Test
void shouldHandleByteAttachment() throws Throwable {
String uuid = "uuid";
- Field field = AllureAttachmentAspect.class.getDeclaredField("userAttachment");
- field.setAccessible(true);
- ThreadLocal tl = (ThreadLocal) field.get(null);
- tl.set(true);
-
ProceedingJoinPoint prepare = mock(ProceedingJoinPoint.class);
when(allure.getCurrentTest()).thenReturn(Optional.of("test"));
@@ -118,18 +97,11 @@ void shouldHandleByteAttachment() throws Throwable {
verify(resolver).find(uuid);
verify(testomatio).artifact("file.txt");
-
- tl.remove();
}
@Test
void shouldHandleStreamAttachment() throws Throwable {
String uuid = "uuid";
- Field field = AllureAttachmentAspect.class.getDeclaredField("userAttachment");
- field.setAccessible(true);
- ThreadLocal tl = (ThreadLocal) field.get(null);
- tl.set(true);
-
ProceedingJoinPoint prepare = mock(ProceedingJoinPoint.class);
when(allure.getCurrentTest()).thenReturn(Optional.of("test"));
@@ -144,8 +116,6 @@ void shouldHandleStreamAttachment() throws Throwable {
aspect.interceptWrite(write);
verify(testomatio).stepArtifact("file.txt");
-
- tl.remove();
}
@Test
@@ -160,4 +130,137 @@ void shouldIgnoreUnknownAttachment() throws Throwable {
verifyNoInteractions(testomatio);
}
+ @Test
+ void shouldNotSendFixtureAttachment() throws Throwable {
+ String uuid = "uuid";
+
+ ProceedingJoinPoint prepare = mock(ProceedingJoinPoint.class);
+ when(allure.getCurrentTest()).thenReturn(Optional.empty());
+ when(allure.getCurrentTestOrStep()).thenReturn(Optional.empty());
+ when(prepare.getArgs()).thenReturn(new Object[]{"file", "text/plain"});
+ when(prepare.proceed()).thenReturn(uuid);
+
+ aspect.interceptPrepare(prepare);
+
+ ProceedingJoinPoint write = mock(ProceedingJoinPoint.class);
+ when(write.getArgs()).thenReturn(new Object[]{uuid, "data".getBytes()});
+ when(write.proceed()).thenReturn(null);
+ when(resolver.find(uuid)).thenReturn("file.txt");
+
+ aspect.interceptWrite(write);
+
+ verifyNoInteractions(testomatio);
+ }
+
+ @Test
+ void shouldRemoveAttachmentAfterWrite() throws Throwable {
+ String uuid = "uuid";
+
+ ProceedingJoinPoint prepare = mock(ProceedingJoinPoint.class);
+
+ when(allure.getCurrentTest()).thenReturn(Optional.of("test"));
+ when(allure.getCurrentTestOrStep()).thenReturn(Optional.of("test"));
+ when(prepare.getArgs()).thenReturn(new Object[]{"file", "text/plain"});
+ when(prepare.proceed()).thenReturn(uuid);
+
+ aspect.interceptPrepare(prepare);
+
+ ProceedingJoinPoint write = mock(ProceedingJoinPoint.class);
+ when(write.getArgs()).thenReturn(new Object[]{uuid, "data".getBytes()});
+ when(write.proceed()).thenReturn(null);
+ when(resolver.find(uuid)).thenReturn("file.txt");
+
+ aspect.interceptWrite(write);
+
+ Field field = AllureAttachmentAspect.class.getDeclaredField("attachments");
+ field.setAccessible(true);
+
+ Map, ?> attachments = (Map, ?>) field.get(null);
+
+ assertThat(attachments.containsKey(uuid)).isFalse();
+ }
+
+ @Test
+ void shouldReturnProceedResultFromWrite() throws Throwable {
+ String uuid = "uuid";
+
+ ProceedingJoinPoint prepare = mock(ProceedingJoinPoint.class);
+
+ when(allure.getCurrentTest()).thenReturn(Optional.of("test"));
+ when(allure.getCurrentTestOrStep()).thenReturn(Optional.of("test"));
+ when(prepare.getArgs()).thenReturn(new Object[]{"file", "text/plain"});
+ when(prepare.proceed()).thenReturn(uuid);
+
+ aspect.interceptPrepare(prepare);
+
+ ProceedingJoinPoint write = mock(ProceedingJoinPoint.class);
+ Object expected = new Object();
+
+ when(write.getArgs()).thenReturn(new Object[]{uuid, "data".getBytes()});
+ when(write.proceed()).thenReturn(expected);
+ when(resolver.find(uuid)).thenReturn("file.txt");
+
+ Object result = aspect.interceptWrite(write);
+
+ assertThat(result).isSameAs(expected);
+ }
+
+ @Test
+ void shouldNotResolveUnknownAttachment() throws Throwable {
+ ProceedingJoinPoint write = mock(ProceedingJoinPoint.class);
+
+ when(write.getArgs()).thenReturn(new Object[]{"unknown", "data".getBytes()});
+ when(write.proceed()).thenReturn(null);
+
+ aspect.interceptWrite(write);
+
+ verifyNoInteractions(resolver);
+ verifyNoInteractions(testomatio);
+ }
+
+ @Test
+ void shouldReturnOriginalFileWhenMimeTypeIsInvalid() throws Exception {
+ String result = invokeAddExtension("file", "invalid/type");
+
+ assertThat(result).isEqualTo("file");
+ }
+
+ @Test
+ void shouldReturnOriginalFileWhenSourceDoesNotExist() throws Exception {
+ String file = "not-existing-file";
+
+ String result = invokeAddExtension(file, "text/plain");
+
+ assertThat(result).isEqualTo(file);
+ }
+
+ @Test
+ void shouldNotAddExtensionWhenAlreadyPresent() throws Exception {
+ String result = invokeAddExtension("file.txt", "text/plain");
+
+ assertThat(result).isEqualTo("file.txt");
+ }
+
+ @Test
+ void shouldCreateCopyWithExtension() throws Exception {
+ Path source = Files.createTempFile("attachment", "");
+
+ String result = invokeAddExtension(source.toString(), "text/plain");
+
+ assertThat(result).endsWith(".txt");
+ assertThat(Files.exists(Path.of(result))).isTrue();
+
+ Files.deleteIfExists(source);
+ Files.deleteIfExists(Path.of(result));
+ }
+
+ private String invokeAddExtension(String fileName, String mimeType) throws Exception {
+ Method method = AllureAttachmentAspect.class
+ .getDeclaredMethod("addExtension", String.class, String.class);
+
+ method.setAccessible(true);
+
+ return (String) method.invoke(aspect, fileName, mimeType);
+ }
+
}
\ No newline at end of file
diff --git a/testomat-allure-adapter/src/test/java/aspect/AllureTmsLinkAspectTest.java b/testomat-allure-adapter/src/test/java/aspect/AllureTmsLinkAspectTest.java
new file mode 100644
index 0000000..0f2148d
--- /dev/null
+++ b/testomat-allure-adapter/src/test/java/aspect/AllureTmsLinkAspectTest.java
@@ -0,0 +1,114 @@
+package aspect;
+
+import io.testomat.aspect.AllureTmsLinkAspect;
+import io.testomat.resolver.AllureTmsResolver;
+import java.lang.reflect.Method;
+import org.aspectj.lang.ProceedingJoinPoint;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.Mockito.*;
+
+class AllureTmsLinkAspectTest {
+
+ private AllureTmsResolver resolver;
+ private AllureTmsLinkAspect aspect;
+
+ @BeforeEach
+ void setup() {
+ resolver = mock(AllureTmsResolver.class);
+ aspect = new AllureTmsLinkAspect(resolver);
+ }
+
+ static class TestClass {
+ void testMethod() {
+ }
+ }
+
+ @Test
+ void shouldReturnExistingTestId() throws Throwable {
+ ProceedingJoinPoint pjp = mock(ProceedingJoinPoint.class);
+
+ when(pjp.proceed()).thenReturn("TMS-123");
+
+ Object result = aspect.intercept(pjp);
+
+ assertThat(result).isEqualTo("TMS-123");
+
+ verify(pjp).proceed();
+ verifyNoInteractions(resolver);
+ }
+
+ @Test
+ void shouldResolveTestIdWhenExtractorReturnsNull() throws Throwable {
+ Method method = TestClass.class.getDeclaredMethod("testMethod");
+
+ ProceedingJoinPoint pjp = mock(ProceedingJoinPoint.class);
+
+ when(pjp.proceed()).thenReturn(null);
+ when(pjp.getArgs()).thenReturn(new Object[]{method});
+ when(resolver.resolve(method)).thenReturn("TMS-456");
+
+ Object result = aspect.intercept(pjp);
+
+ assertThat(result).isEqualTo("TMS-456");
+
+ verify(pjp).proceed();
+ verify(resolver).resolve(method);
+ }
+
+ @Test
+ void shouldResolveTestIdWhenExtractorReturnsBlank() throws Throwable {
+ Method method = TestClass.class.getDeclaredMethod("testMethod");
+
+ ProceedingJoinPoint pjp = mock(ProceedingJoinPoint.class);
+
+ when(pjp.proceed()).thenReturn(" ");
+ when(pjp.getArgs()).thenReturn(new Object[]{method});
+ when(resolver.resolve(method)).thenReturn("TMS-789");
+
+ Object result = aspect.intercept(pjp);
+
+ assertThat(result).isEqualTo("TMS-789");
+
+ verify(pjp).proceed();
+ verify(resolver).resolve(method);
+ }
+
+ @Test
+ void shouldReturnNullWhenNothingResolved() throws Throwable {
+ Method method = TestClass.class.getDeclaredMethod("testMethod");
+
+ ProceedingJoinPoint pjp = mock(ProceedingJoinPoint.class);
+
+ when(pjp.proceed()).thenReturn(null);
+ when(pjp.getArgs()).thenReturn(new Object[]{method});
+ when(resolver.resolve(method)).thenReturn(null);
+
+ Object result = aspect.intercept(pjp);
+
+ assertThat(result).isNull();
+
+ verify(pjp).proceed();
+ verify(resolver).resolve(method);
+ }
+
+ @Test
+ void shouldReturnBlankWhenResolverReturnsNull() throws Throwable {
+ Method method = TestClass.class.getDeclaredMethod("testMethod");
+
+ ProceedingJoinPoint pjp = mock(ProceedingJoinPoint.class);
+
+ when(pjp.proceed()).thenReturn("");
+ when(pjp.getArgs()).thenReturn(new Object[]{method});
+ when(resolver.resolve(method)).thenReturn(null);
+
+ Object result = aspect.intercept(pjp);
+
+ assertThat(result).isEqualTo("");
+
+ verify(pjp).proceed();
+ verify(resolver).resolve(method);
+ }
+}
\ No newline at end of file
diff --git a/testomat-allure-adapter/src/test/java/resolver/AllureTmsResolverTest.java b/testomat-allure-adapter/src/test/java/resolver/AllureTmsResolverTest.java
new file mode 100644
index 0000000..c4d1cc6
--- /dev/null
+++ b/testomat-allure-adapter/src/test/java/resolver/AllureTmsResolverTest.java
@@ -0,0 +1,47 @@
+package resolver;
+
+import io.qameta.allure.TmsLink;
+import io.testomat.resolver.AllureTmsResolver;
+import java.lang.reflect.Method;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class AllureTmsResolverTest {
+
+ private AllureTmsResolver resolver;
+
+ @BeforeEach
+ void setup() {
+ resolver = new AllureTmsResolver();
+ }
+
+ static class TestClass {
+
+ @TmsLink("TMS-123")
+ void methodWithTmsLink() {
+ }
+
+ void methodWithoutTmsLink() {
+ }
+ }
+
+ @Test
+ void shouldReturnTmsLinkValue() throws Exception {
+ Method method = TestClass.class.getDeclaredMethod("methodWithTmsLink");
+
+ String result = resolver.resolve(method);
+
+ assertThat(result).isEqualTo("TMS-123");
+ }
+
+ @Test
+ void shouldReturnNullWhenTmsLinkIsAbsent() throws Exception {
+ Method method = TestClass.class.getDeclaredMethod("methodWithoutTmsLink");
+
+ String result = resolver.resolve(method);
+
+ assertThat(result).isNull();
+ }
+}
\ No newline at end of file