From 6ec3b155557c829375ffca9625ff70c7386c83e5 Mon Sep 17 00:00:00 2001 From: qameta-ci Date: Fri, 20 Feb 2026 15:46:09 +0000 Subject: [PATCH 01/13] set next development version 2.34 --- gradle.properties | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle.properties b/gradle.properties index 03684fa3..ffccaed3 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,4 +1,4 @@ -version=2.33.0 +version=2.34-SNAPSHOT org.gradle.daemon=true org.gradle.parallel=true From 89ac78e12c678d76ad3b857226a5c35bcf976e0d Mon Sep 17 00:00:00 2001 From: Denys Gaievskyi Date: Fri, 20 Feb 2026 17:01:47 +0100 Subject: [PATCH 02/13] Prevent IndexOutOfBoundsException for synthetic parameters in Kotlin suspend functions (#1231) --- .../io/qameta/allure/junit5/AllureJunit5.java | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/allure-junit5/src/main/java/io/qameta/allure/junit5/AllureJunit5.java b/allure-junit5/src/main/java/io/qameta/allure/junit5/AllureJunit5.java index 5bd1f59a..3d1019a2 100644 --- a/allure-junit5/src/main/java/io/qameta/allure/junit5/AllureJunit5.java +++ b/allure-junit5/src/main/java/io/qameta/allure/junit5/AllureJunit5.java @@ -28,6 +28,7 @@ import java.lang.reflect.Parameter; import java.util.HashMap; import java.util.Map; +import java.util.List; import java.util.Objects; import java.util.Optional; import java.util.UUID; @@ -62,15 +63,20 @@ public void interceptTestTemplateMethod(final Invocation invocation, private void sendParameterEvent(final ReflectiveInvocationContext invocationContext, final ExtensionContext extensionContext) { final Parameter[] parameters = invocationContext.getExecutable().getParameters(); - for (int i = 0; i < parameters.length; i++) { - final Parameter parameter = parameters[i]; + final List arguments = invocationContext.getArguments(); + int argumentIndex = 0; + for (final Parameter parameter : parameters) { final Class parameterType = parameter.getType(); - // Skip default jupiter injectables as TestInfo, TestReporter and TempDirectory - if (parameterType.getCanonicalName().startsWith("org.junit.jupiter.api")) { + + // Skip JUnit injectables AND synthetic parameters + if (parameterType.getCanonicalName().startsWith("org.junit.jupiter.api") + || parameter.isSynthetic() + || argumentIndex >= arguments.size()) { continue; } - final Object value = invocationContext.getArguments().get(i); + + final Object value = arguments.get(argumentIndex++); final Map map = new HashMap<>(); map.put(ALLURE_PARAMETER, parameter.getName()); map.put(ALLURE_PARAMETER_VALUE_KEY, ObjectUtils.toString(value)); From 87629802184c78c61172f7f3f605eeb8b1feb78d Mon Sep 17 00:00:00 2001 From: Dmitry Baev Date: Fri, 20 Feb 2026 16:39:35 +0000 Subject: [PATCH 03/13] enable automatic release publishing (via #1247) --- .github/workflows/publish.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 2295deb9..fd0a661c 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -30,7 +30,7 @@ jobs: - name: "Gradle Publish" run: | - ./gradlew publishToSonatype closeSonatypeStagingRepository -Pversion=${GITHUB_REF:10} \ + ./gradlew publishToSonatype closeAndReleaseSonatypeStagingRepository -Pversion=${GITHUB_REF:10} \ -Psigning.keyId=${GPG_KEY_ID} \ -Psigning.password=${GPG_PASSPHRASE} \ -Psigning.secretKeyRingFile=${GITHUB_WORKSPACE}/${GPG_KEY_ID}.gpg From 0e87c21a6f3417d592f5b481107ad715d49c5dce Mon Sep 17 00:00:00 2001 From: skuznetsov-al Date: Fri, 20 Feb 2026 21:57:36 +0300 Subject: [PATCH 04/13] cover parameterized tests with injectables (#1248) --- .../AllureJunit5Junit6CompatibilityTest.java | 161 ++++++++++++++++++ .../allure/junit5/AllureJunit5Test.java | 38 +++++ .../ParameterisedWithInjectablesTests.java | 29 ++++ 3 files changed, 228 insertions(+) create mode 100644 allure-junit5/src/test/java/io/qameta/allure/junit5/AllureJunit5Junit6CompatibilityTest.java create mode 100644 allure-junit5/src/test/java/io/qameta/allure/junit5/features/ParameterisedWithInjectablesTests.java diff --git a/allure-junit5/src/test/java/io/qameta/allure/junit5/AllureJunit5Junit6CompatibilityTest.java b/allure-junit5/src/test/java/io/qameta/allure/junit5/AllureJunit5Junit6CompatibilityTest.java new file mode 100644 index 00000000..a9d85cc2 --- /dev/null +++ b/allure-junit5/src/test/java/io/qameta/allure/junit5/AllureJunit5Junit6CompatibilityTest.java @@ -0,0 +1,161 @@ +/* + * Copyright 2016-2026 Qameta Software Inc + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.qameta.allure.junit5; + +import io.qameta.allure.Allure; +import io.qameta.allure.Param; +import io.qameta.allure.junitplatform.AllureJunitPlatform; +import io.qameta.allure.model.FixtureResult; +import io.qameta.allure.model.Parameter; +import io.qameta.allure.model.Status; +import io.qameta.allure.model.StepResult; +import io.qameta.allure.model.TestResult; +import io.qameta.allure.model.TestResultContainer; +import io.qameta.allure.test.AllureResults; +import io.qameta.allure.test.RunUtils; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.platform.engine.discovery.ClassSelector; +import org.junit.platform.engine.discovery.DiscoverySelectors; +import org.junit.platform.launcher.Launcher; +import org.junit.platform.launcher.LauncherDiscoveryRequest; +import org.junit.platform.launcher.core.LauncherConfig; +import org.junit.platform.launcher.core.LauncherDiscoveryRequestBuilder; +import org.junit.platform.launcher.core.LauncherFactory; + +import java.util.List; +import java.util.stream.Stream; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.tuple; + +@Tag("junit6-compat") +@SuppressWarnings("unused") +class AllureJunit5Junit6CompatibilityTest { + + @ExtendWith(AllureJunit5.class) + static class CompatParametersTest { + + @org.junit.jupiter.params.ParameterizedTest + @org.junit.jupiter.params.provider.ValueSource(strings = {"a", "b"}) + void paramTest(@Param("id") String value) { + } + } + + @Nested + @ExtendWith(AllureJunit5.class) + class CompatFixtures { + + @BeforeEach + void setUp() { + Allure.step("before step"); + } + + @Test + void testBody() { + Allure.step("test step"); + } + + @AfterEach + void tearDown() { + Allure.step("after step"); + } + } + + @Test + void shouldCaptureParametersWithParamAnnotationOnJunit6() { + AllureResults results = runWithLauncher(CompatParametersTest.class); + + assertThat(results.getTestResults()).isNotEmpty(); + + List allParams = results.getTestResults().stream() + .flatMap(tr -> tr.getParameters().stream()) + .toList(); + + assertThat(allParams) + .isNotEmpty() + .extracting(Parameter::getName, Parameter::getValue) + .contains( + tuple("id", "a"), + tuple("id", "b") + ); + } + + @Test + void shouldCaptureFixturesAndStepsOnJunit6() { + AllureResults results = runWithLauncher(CompatFixtures.class); + + assertThat(results.getTestResults()).hasSize(1); + TestResult testResult = results.getTestResults().get(0); + + assertThat(results.getTestResultContainers()) + .flatExtracting(TestResultContainer::getChildren) + .contains(testResult.getUuid()); + + assertThat(results.getTestResultContainers()) + .flatExtracting(TestResultContainer::getBefores) + .extracting(FixtureResult::getStatus) + .contains(Status.PASSED); + + assertThat(results.getTestResultContainers()) + .flatExtracting(TestResultContainer::getAfters) + .extracting(FixtureResult::getStatus) + .contains(Status.PASSED); + + assertThat(results.getTestResultContainers()) + .flatExtracting(TestResultContainer::getBefores) + .flatExtracting(FixtureResult::getSteps) + .extracting(StepResult::getName) + .contains("before step"); + + assertThat(results.getTestResults()) + .flatExtracting(TestResult::getSteps) + .extracting(StepResult::getName) + .contains("test step"); + + assertThat(results.getTestResultContainers()) + .flatExtracting(TestResultContainer::getAfters) + .flatExtracting(FixtureResult::getSteps) + .extracting(StepResult::getName) + .contains("after step"); + } + + @io.qameta.allure.Step("Run classes {classes}") + private AllureResults runWithLauncher(Class... classes) { + return RunUtils.runTests(lifecycle -> { + ClassSelector[] selectors = Stream.of(classes) + .map(DiscoverySelectors::selectClass) + .toArray(ClassSelector[]::new); + + LauncherDiscoveryRequest request = LauncherDiscoveryRequestBuilder.request() + .configurationParameter("junit.jupiter.extensions.autodetection.enabled", "true") + .selectors(selectors) + .build(); + + LauncherConfig config = LauncherConfig.builder() + .enableTestExecutionListenerAutoRegistration(false) + .addTestExecutionListeners(new AllureJunitPlatform(lifecycle)) + .build(); + + Launcher launcher = LauncherFactory.create(config); + launcher.execute(request); + }); + } +} diff --git a/allure-junit5/src/test/java/io/qameta/allure/junit5/AllureJunit5Test.java b/allure-junit5/src/test/java/io/qameta/allure/junit5/AllureJunit5Test.java index 0712c887..66d102e2 100644 --- a/allure-junit5/src/test/java/io/qameta/allure/junit5/AllureJunit5Test.java +++ b/allure-junit5/src/test/java/io/qameta/allure/junit5/AllureJunit5Test.java @@ -25,6 +25,7 @@ import io.qameta.allure.junit5.features.ParameterisedBlankParameterValueTests; import io.qameta.allure.junit5.features.ParameterisedPrimitivesTests; import io.qameta.allure.junit5.features.ParameterisedTests; +import io.qameta.allure.junit5.features.ParameterisedWithInjectablesTests; import io.qameta.allure.junit5.features.SkipOtherInjectables; import io.qameta.allure.junitplatform.AllureJunitPlatform; import io.qameta.allure.model.FixtureResult; @@ -181,6 +182,43 @@ void shouldSkipReportingOfTestInjectablesTestReporterForParameterisedTest() { ); } + @Test + void shouldReportParametersWhenTestHasInjectableArgument() { + final AllureResults results = runClasses(ParameterisedWithInjectablesTests.class); + + assertThat(results.getTestResults()) + .filteredOn("fullName", "io.qameta.allure.junit5.features.ParameterisedWithInjectablesTests.valueAndReporter") + .filteredOn("name", "valueAndReporter(String, TestReporter) [1] value=a") + .flatExtracting(TestResult::getParameters) + .extracting(Parameter::getName, Parameter::getValue) + .contains( + tuple("value", "a") + ); + + assertThat(results.getTestResults()) + .filteredOn("fullName", "io.qameta.allure.junit5.features.ParameterisedWithInjectablesTests.valueAndReporter") + .filteredOn("name", "valueAndReporter(String, TestReporter) [1] value=a") + .flatExtracting(TestResult::getParameters) + .extracting(Parameter::getName) + .doesNotContain("testReporter"); + + assertThat(results.getTestResults()) + .filteredOn("fullName", "io.qameta.allure.junit5.features.ParameterisedWithInjectablesTests.valueAndReporter") + .filteredOn("name", "valueAndReporter(String, TestReporter) [2] value=b") + .flatExtracting(TestResult::getParameters) + .extracting(Parameter::getName, Parameter::getValue) + .contains( + tuple("value", "b") + ); + + assertThat(results.getTestResults()) + .filteredOn("fullName", "io.qameta.allure.junit5.features.ParameterisedWithInjectablesTests.valueAndReporter") + .filteredOn("name", "valueAndReporter(String, TestReporter) [2] value=b") + .flatExtracting(TestResult::getParameters) + .extracting(Parameter::getName) + .doesNotContain("testReporter"); + } + @Test void shouldSupportBeforeEachFixture() { final AllureResults results = runClasses(EachFixtureSupport.class); diff --git a/allure-junit5/src/test/java/io/qameta/allure/junit5/features/ParameterisedWithInjectablesTests.java b/allure-junit5/src/test/java/io/qameta/allure/junit5/features/ParameterisedWithInjectablesTests.java new file mode 100644 index 00000000..7ef9b020 --- /dev/null +++ b/allure-junit5/src/test/java/io/qameta/allure/junit5/features/ParameterisedWithInjectablesTests.java @@ -0,0 +1,29 @@ +/* + * Copyright 2016-2026 Qameta Software Inc + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.qameta.allure.junit5.features; + +import org.junit.jupiter.api.TestReporter; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +public class ParameterisedWithInjectablesTests { + + @ParameterizedTest + @ValueSource(strings = {"a", "b"}) + void valueAndReporter(final String value, final TestReporter testReporter) { + testReporter.publishEntry("value", value); + } +} From bf6d32a0c4dd9dfac054d4b1d4a2ef1219846608 Mon Sep 17 00:00:00 2001 From: skuznetsov-al Date: Thu, 12 Mar 2026 12:23:26 +0300 Subject: [PATCH 05/13] Update jackson bom version (#1250) --- build.gradle.kts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/build.gradle.kts b/build.gradle.kts index 15428109..b3a82e14 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -149,25 +149,30 @@ configure(libs) { dependencyManagement { imports { - mavenBom("com.fasterxml.jackson:jackson-bom:2.20.1") + mavenBom("com.fasterxml.jackson:jackson-bom:2.21.1") mavenBom("org.junit:junit-bom:5.10.3") } dependencies { dependency("com.github.spotbugs:spotbugs:4.9.8") dependency("com.github.tomakehurst:wiremock:3.0.1") + dependency("com.google.code.gson:gson:2.8.9") + dependency("com.google.guava:guava:32.0.1-jre") dependency("com.google.inject:guice:7.0.0") dependency("com.google.testing.compile:compile-testing:0.23.0") dependency("com.puppycrawl.tools:checkstyle:12.3.0") dependency("com.squareup.retrofit2:retrofit:3.0.0") dependency("commons-io:commons-io:2.20.0") + dependency("commons-beanutils:commons-beanutils:1.11.0") dependency("io.github.benas:random-beans:3.9.0") dependency("io.github.glytching:junit-extensions:2.6.0") dependency("javax.annotation:javax.annotation-api:1.3.2") dependency("net.sourceforge.pmd:pmd-java:7.15.0") - dependency("org.apache.commons:commons-lang3:3.15.0") + dependency("org.apache.commons:commons-lang3:3.18.0") + dependency("org.apache.commons:commons-text:1.10.0") dependency("org.aspectj:aspectjrt:${assertJVersion}") dependency("org.aspectj:aspectjweaver:${assertJVersion}") dependency("org.assertj:assertj-core:3.27.7") + dependency("junit:junit:4.13.2") dependency("org.freemarker:freemarker:2.3.33") dependency("org.grpcmock:grpcmock-junit5:0.8.0") dependency("org.hamcrest:hamcrest:3.0") From 4e596326d3677738f31c77ada0fa43e46495f8c3 Mon Sep 17 00:00:00 2001 From: skuznetsov-al Date: Mon, 23 Mar 2026 16:47:11 +0300 Subject: [PATCH 06/13] prevent spock2 uuid reuse (#1251) --- .../io/qameta/allure/spock2/AllureSpock2.java | 36 +++++----- .../allure/spock2/AllureSpock2Test.java | 67 +++++++++++++++++++ 2 files changed, 88 insertions(+), 15 deletions(-) diff --git a/allure-spock2/src/main/java/io/qameta/allure/spock2/AllureSpock2.java b/allure-spock2/src/main/java/io/qameta/allure/spock2/AllureSpock2.java index 99a7de32..2a852407 100644 --- a/allure-spock2/src/main/java/io/qameta/allure/spock2/AllureSpock2.java +++ b/allure-spock2/src/main/java/io/qameta/allure/spock2/AllureSpock2.java @@ -82,12 +82,7 @@ */ public class AllureSpock2 extends AbstractRunListener implements IGlobalExtension { - private final ThreadLocal testResults = new InheritableThreadLocal() { - @Override - protected String initialValue() { - return UUID.randomUUID().toString(); - } - }; + private final ThreadLocal testResults = new ThreadLocal<>(); private final AllureLifecycle lifecycle; @@ -142,7 +137,7 @@ public void visitSpec(final SpecInfo spec) { @Override public void beforeIteration(final IterationInfo iteration) { - final String uuid = testResults.get(); + final String uuid = UUID.randomUUID().toString(); final FeatureInfo feature = iteration.getFeature(); final MethodInfo methodInfo = feature.getFeatureMethod(); @@ -222,6 +217,7 @@ public void beforeIteration(final IterationInfo iteration) { result::setDescriptionHtml ); + testResults.set(uuid); getLifecycle().scheduleTestCase(result); getLifecycle().startTestCase(uuid); @@ -285,6 +281,9 @@ private boolean match(final TestPlanV1_0.TestCase tc, final String allureId, fin @Override public void error(final ErrorInfo error) { final String uuid = testResults.get(); + if (Objects.isNull(uuid)) { + return; + } getLifecycle().updateTestCase(uuid, testResult -> testResult .setStatus(getStatus(error.getException()).orElse(null)) .setStatusDetails(getStatusDetails(error.getException()).orElse(null)) @@ -294,15 +293,22 @@ public void error(final ErrorInfo error) { @Override public void afterIteration(final IterationInfo iteration) { final String uuid = testResults.get(); - testResults.remove(); + if (Objects.isNull(uuid)) { + testResults.remove(); + return; + } - getLifecycle().updateTestCase(uuid, testResult -> { - if (Objects.isNull(testResult.getStatus())) { - testResult.setStatus(Status.PASSED); - } - }); - getLifecycle().stopTestCase(uuid); - getLifecycle().writeTestCase(uuid); + try { + getLifecycle().updateTestCase(uuid, testResult -> { + if (Objects.isNull(testResult.getStatus())) { + testResult.setStatus(Status.PASSED); + } + }); + getLifecycle().stopTestCase(uuid); + getLifecycle().writeTestCase(uuid); + } finally { + testResults.remove(); + } } private List getParameters(final List names, final Object... values) { diff --git a/allure-spock2/src/test/groovy/io/qameta/allure/spock2/AllureSpock2Test.java b/allure-spock2/src/test/groovy/io/qameta/allure/spock2/AllureSpock2Test.java index 93145e00..3771a655 100644 --- a/allure-spock2/src/test/groovy/io/qameta/allure/spock2/AllureSpock2Test.java +++ b/allure-spock2/src/test/groovy/io/qameta/allure/spock2/AllureSpock2Test.java @@ -61,7 +61,9 @@ import org.spockframework.runtime.GlobalExtensionRegistry; import org.spockframework.runtime.RunContext; import org.spockframework.runtime.SpockEngine; +import org.mockito.ArgumentCaptor; +import java.lang.reflect.Method; import java.time.Instant; import java.util.Arrays; import java.util.Collection; @@ -73,12 +75,77 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.tuple; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; /** * @author charlie (Dmitry Baev). */ class AllureSpock2Test { + @Test + void shouldCreateNewUuidForEachIteration() throws Exception { + final io.qameta.allure.AllureLifecycle lifecycle = mock(io.qameta.allure.AllureLifecycle.class); + final AllureSpock2 allureSpock2 = new AllureSpock2(lifecycle); + + final org.spockframework.runtime.model.SpecInfo specInfo = mock(org.spockframework.runtime.model.SpecInfo.class); + final org.spockframework.runtime.model.FeatureInfo featureInfo = mock(org.spockframework.runtime.model.FeatureInfo.class); + final org.spockframework.runtime.model.MethodInfo methodInfo = mock(org.spockframework.runtime.model.MethodInfo.class); + final org.spockframework.runtime.model.IterationInfo iterationInfo = mock(org.spockframework.runtime.model.IterationInfo.class); + + final Method method = DummySpec.class.getMethod("dummy"); + when(methodInfo.getReflection()).thenReturn(method); + + when(specInfo.getReflection()).thenReturn((Class) DummySpec.class); + when(specInfo.getPackage()).thenReturn(DummySpec.class.getPackage().getName()); + when(specInfo.getName()).thenReturn(DummySpec.class.getSimpleName()); + when(specInfo.getSubSpec()).thenReturn(null); + when(specInfo.getSuperSpec()).thenReturn(null); + + when(featureInfo.getFeatureMethod()).thenReturn(methodInfo); + when(featureInfo.getSpec()).thenReturn(specInfo); + when(featureInfo.getDataVariables()).thenReturn(Collections.emptyList()); + when(featureInfo.getTestTags()).thenReturn(Collections.emptySet()); + + when(iterationInfo.getFeature()).thenReturn(featureInfo); + when(iterationInfo.getDataValues()).thenReturn(new Object[0]); + when(iterationInfo.getDisplayName()).thenReturn("dummy"); + when(iterationInfo.getName()).thenReturn("dummy"); + + final ArgumentCaptor scheduled = ArgumentCaptor.forClass(TestResult.class); + + allureSpock2.beforeIteration(iterationInfo); + allureSpock2.beforeIteration(iterationInfo); + + verify(lifecycle, times(2)).scheduleTestCase(scheduled.capture()); + final List captured = scheduled.getAllValues(); + assertThat(captured) + .hasSize(2) + .extracting(TestResult::getUuid) + .doesNotContainNull(); + assertThat(captured.get(0).getUuid()).isNotEqualTo(captured.get(1).getUuid()); + } + + @Test + void shouldIgnoreErrorAndAfterIterationWhenUuidMissing() { + final io.qameta.allure.AllureLifecycle lifecycle = mock(io.qameta.allure.AllureLifecycle.class); + final AllureSpock2 allureSpock2 = new AllureSpock2(lifecycle); + + allureSpock2.error(mock(org.spockframework.runtime.model.ErrorInfo.class)); + allureSpock2.afterIteration(mock(org.spockframework.runtime.model.IterationInfo.class)); + + verifyNoInteractions(lifecycle); + } + + private static final class DummySpec { + public void dummy() { + // noop + } + } + @Test void shouldStoreTestsInformation() { final AllureResults results = runClasses(OneTest.class); From d94943d8e475b6cc6a4eebe830f41fa78daac012 Mon Sep 17 00:00:00 2001 From: skuznetsov-al Date: Wed, 25 Mar 2026 20:30:02 +0300 Subject: [PATCH 07/13] add optional clean results before run (#1253) --- .../io/qameta/allure/AllureLifecycle.java | 6 +- .../allure/FileSystemResultsWriter.java | 56 ++++++++++++- .../allure/FileSystemResultsWriterTest.java | 82 +++++++++++++++++++ 3 files changed, 140 insertions(+), 4 deletions(-) diff --git a/allure-java-commons/src/main/java/io/qameta/allure/AllureLifecycle.java b/allure-java-commons/src/main/java/io/qameta/allure/AllureLifecycle.java index 0edf0170..dc3f9d3b 100644 --- a/allure-java-commons/src/main/java/io/qameta/allure/AllureLifecycle.java +++ b/allure-java-commons/src/main/java/io/qameta/allure/AllureLifecycle.java @@ -640,7 +640,11 @@ private boolean isEmpty(final String s) { private static FileSystemResultsWriter getDefaultWriter() { final Properties properties = PropertiesUtils.loadAllureProperties(); final String path = properties.getProperty("allure.results.directory", "allure-results"); - return new FileSystemResultsWriter(Paths.get(path)); + final boolean cleanBeforeRun = Boolean.parseBoolean( + properties.getProperty("allure.results.clean.before.run", "false")); + final boolean cleanOnlyOnce = Boolean.parseBoolean( + properties.getProperty("allure.results.clean.only.once", "true")); + return new FileSystemResultsWriter(Paths.get(path), cleanBeforeRun, cleanOnlyOnce); } private static LifecycleNotifier getDefaultNotifier() { diff --git a/allure-java-commons/src/main/java/io/qameta/allure/FileSystemResultsWriter.java b/allure-java-commons/src/main/java/io/qameta/allure/FileSystemResultsWriter.java index 7bbcaf22..f4b0e331 100644 --- a/allure-java-commons/src/main/java/io/qameta/allure/FileSystemResultsWriter.java +++ b/allure-java-commons/src/main/java/io/qameta/allure/FileSystemResultsWriter.java @@ -19,25 +19,46 @@ import io.qameta.allure.internal.Allure2ModelJackson; import io.qameta.allure.model.TestResult; import io.qameta.allure.model.TestResultContainer; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import java.io.IOException; import java.io.InputStream; import java.nio.file.Files; import java.nio.file.Path; +import java.util.Comparator; import java.util.Objects; import java.util.UUID; +import java.util.stream.Stream; +import java.util.concurrent.atomic.AtomicBoolean; /** * @author charlie (Dmitry Baev). */ public class FileSystemResultsWriter implements AllureResultsWriter { + private static final Logger LOGGER = LoggerFactory.getLogger(FileSystemResultsWriter.class); + private final Path outputDirectory; private final ObjectMapper mapper; + private final boolean cleanBeforeRun; + + private final boolean cleanOnlyOnce; + + private final AtomicBoolean cleaned = new AtomicBoolean(false); + public FileSystemResultsWriter(final Path outputDirectory) { + this(outputDirectory, false, true); + } + + public FileSystemResultsWriter(final Path outputDirectory, + final boolean cleanBeforeRun, + final boolean cleanOnlyOnce) { this.outputDirectory = outputDirectory; + this.cleanBeforeRun = cleanBeforeRun; + this.cleanOnlyOnce = cleanOnlyOnce; this.mapper = Allure2ModelJackson.createMapper(); } @@ -46,7 +67,7 @@ public void write(final TestResult testResult) { final String testResultName = Objects.isNull(testResult.getUuid()) ? generateTestResultName() : generateTestResultName(testResult.getUuid()); - createDirectories(outputDirectory); + ensureInitialized(); final Path file = outputDirectory.resolve(testResultName); try { mapper.writeValue(file.toFile(), testResult); @@ -60,7 +81,7 @@ public void write(final TestResultContainer testResultContainer) { final String testResultContainerName = Objects.isNull(testResultContainer.getUuid()) ? generateTestResultContainerName() : generateTestResultContainerName(testResultContainer.getUuid()); - createDirectories(outputDirectory); + ensureInitialized(); final Path file = outputDirectory.resolve(testResultContainerName); try { mapper.writeValue(file.toFile(), testResultContainer); @@ -71,7 +92,7 @@ public void write(final TestResultContainer testResultContainer) { @Override public void write(final String source, final InputStream attachment) { - createDirectories(outputDirectory); + ensureInitialized(); final Path file = outputDirectory.resolve(source); try (InputStream is = attachment) { Files.copy(is, file); @@ -88,6 +109,35 @@ private void createDirectories(final Path directory) { } } + private void ensureInitialized() { + createDirectories(outputDirectory); + if (cleanBeforeRun) { + final boolean shouldClean = !cleanOnlyOnce || cleaned.compareAndSet(false, true); + if (shouldClean) { + cleanDirectoryContents(outputDirectory); + } + } + } + + private void cleanDirectoryContents(final Path directory) { + if (!Files.exists(directory)) { + return; + } + try (Stream stream = Files.walk(directory)) { + stream.sorted(Comparator.reverseOrder()) + .filter(path -> !path.equals(directory)) + .forEach(path -> { + try { + Files.deleteIfExists(path); + } catch (IOException e) { + LOGGER.warn("Failed to delete {} during directory cleanup", path, e); + } + }); + } catch (IOException e) { + LOGGER.warn("Failed to clean directory contents: {}", directory, e); + } + } + protected static String generateTestResultName() { return generateTestResultName(UUID.randomUUID().toString()); } diff --git a/allure-java-commons/src/test/java/io/qameta/allure/FileSystemResultsWriterTest.java b/allure-java-commons/src/test/java/io/qameta/allure/FileSystemResultsWriterTest.java index c16d25d1..e84be69b 100644 --- a/allure-java-commons/src/test/java/io/qameta/allure/FileSystemResultsWriterTest.java +++ b/allure-java-commons/src/test/java/io/qameta/allure/FileSystemResultsWriterTest.java @@ -19,6 +19,8 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; +import java.io.IOException; +import java.nio.file.Files; import java.nio.file.Path; import java.util.UUID; @@ -53,4 +55,84 @@ void shouldWriteTestResult(@TempDir final Path folder) { assertThat(folder.resolve(fileName)) .isRegularFile(); } + + @Test + void shouldPreserveOldResultsWhenCleanIsDisabled(@TempDir final Path folder) throws IOException { + Path existingFile = folder.resolve("existing-result.json"); + Files.writeString(existingFile, "{}"); + + FileSystemResultsWriter writer = new FileSystemResultsWriter(folder, false, true); + final String uuid = UUID.randomUUID().toString(); + final TestResult testResult = current().nextObject(TestResult.class, "steps").setUuid(uuid); + writer.write(testResult); + + assertThat(existingFile).exists(); + assertThat(folder.resolve(generateTestResultName(uuid))).exists(); + } + + @Test + void shouldCleanDirectoryWhenCleanBeforeRunEnabled(@TempDir final Path folder) throws IOException { + Path existingFile = folder.resolve("existing-result.json"); + Files.writeString(existingFile, "{}"); + + FileSystemResultsWriter writer = new FileSystemResultsWriter(folder, true, true); + final String uuid = UUID.randomUUID().toString(); + final TestResult testResult = current().nextObject(TestResult.class, "steps").setUuid(uuid); + writer.write(testResult); + + assertThat(existingFile).doesNotExist(); + assertThat(folder.resolve(generateTestResultName(uuid))).exists(); + } + + @Test + void shouldCleanOnlyOnceWhenCleanOnlyOnceEnabled(@TempDir final Path folder) throws IOException { + Path existingFile = folder.resolve("existing-result.json"); + Files.writeString(existingFile, "{}"); + + FileSystemResultsWriter writer = new FileSystemResultsWriter(folder, true, true); + + final String uuid1 = UUID.randomUUID().toString(); + final TestResult testResult1 = current().nextObject(TestResult.class, "steps").setUuid(uuid1); + writer.write(testResult1); + + final String uuid2 = UUID.randomUUID().toString(); + final TestResult testResult2 = current().nextObject(TestResult.class, "steps").setUuid(uuid2); + writer.write(testResult2); + + assertThat(folder.resolve(generateTestResultName(uuid1))).exists(); + assertThat(folder.resolve(generateTestResultName(uuid2))).exists(); + } + + @Test + void shouldCleanOnEveryFirstWriteWhenCleanOnlyOnceDisabled(@TempDir final Path folder) throws IOException { + FileSystemResultsWriter writer1 = new FileSystemResultsWriter(folder, true, false); + final String uuid1 = UUID.randomUUID().toString(); + final TestResult testResult1 = current().nextObject(TestResult.class, "steps").setUuid(uuid1); + writer1.write(testResult1); + + Path intermediateFile = folder.resolve("intermediate-result.json"); + Files.writeString(intermediateFile, "{}"); + + FileSystemResultsWriter writer2 = new FileSystemResultsWriter(folder, true, false); + final String uuid2 = UUID.randomUUID().toString(); + final TestResult testResult2 = current().nextObject(TestResult.class, "steps").setUuid(uuid2); + writer2.write(testResult2); + + assertThat(intermediateFile).doesNotExist(); + assertThat(folder.resolve(generateTestResultName(uuid2))).exists(); + } + + @Test + void shouldNotDeleteDirectoryItself(@TempDir final Path folder) throws IOException { + Path existingFile = folder.resolve("existing-result.json"); + Files.writeString(existingFile, "{}"); + + FileSystemResultsWriter writer = new FileSystemResultsWriter(folder, true, true); + final String uuid = UUID.randomUUID().toString(); + final TestResult testResult = current().nextObject(TestResult.class, "steps").setUuid(uuid); + writer.write(testResult); + + assertThat(folder).isDirectory(); + assertThat(folder.resolve(generateTestResultName(uuid))).exists(); + } } From c1957f0640d1461448b9cd9cc6dc918cd932bd80 Mon Sep 17 00:00:00 2001 From: Dmitry Baev Date: Wed, 1 Apr 2026 16:13:23 +0100 Subject: [PATCH 08/13] fix(rest-assured): handle non-string form params in request attachments (fixes #1025, via #1256) --- .../allure/restassured/AllureRestAssured.java | 9 +- .../restassured/AllureRestAssuredTest.java | 151 +++++++++++++----- 2 files changed, 122 insertions(+), 38 deletions(-) diff --git a/allure-rest-assured/src/main/java/io/qameta/allure/restassured/AllureRestAssured.java b/allure-rest-assured/src/main/java/io/qameta/allure/restassured/AllureRestAssured.java index a4c62a18..d59e998a 100644 --- a/allure-rest-assured/src/main/java/io/qameta/allure/restassured/AllureRestAssured.java +++ b/allure-rest-assured/src/main/java/io/qameta/allure/restassured/AllureRestAssured.java @@ -19,6 +19,7 @@ import io.qameta.allure.attachment.FreemarkerAttachmentRenderer; import io.qameta.allure.attachment.http.HttpRequestAttachment; import io.qameta.allure.attachment.http.HttpResponseAttachment; +import io.qameta.allure.util.ObjectUtils; import io.restassured.filter.FilterContext; import io.restassured.filter.OrderedFilter; import io.restassured.internal.NameAndValue; @@ -114,7 +115,7 @@ public Response filter(final FilterableRequestSpecification requestSpec, } if (Objects.nonNull(requestSpec.getFormParams())) { - requestAttachmentBuilder.setFormParams(requestSpec.getFormParams()); + requestAttachmentBuilder.setFormParams(toStringMapConverter(requestSpec.getFormParams())); } final HttpRequestAttachment requestAttachment = requestAttachmentBuilder.build(); @@ -155,6 +156,12 @@ private static Map toMapConverter(final Iterable toStringMapConverter(final Map items) { + final Map result = new HashMap<>(); + items.forEach((key, value) -> result.put(key, ObjectUtils.toString(value))); + return result; + } + @Override public int getOrder() { return Integer.MAX_VALUE; diff --git a/allure-rest-assured/src/test/java/io/qameta/allure/restassured/AllureRestAssuredTest.java b/allure-rest-assured/src/test/java/io/qameta/allure/restassured/AllureRestAssuredTest.java index 8a2d6e29..2210b7dc 100644 --- a/allure-rest-assured/src/test/java/io/qameta/allure/restassured/AllureRestAssuredTest.java +++ b/allure-rest-assured/src/test/java/io/qameta/allure/restassured/AllureRestAssuredTest.java @@ -27,10 +27,6 @@ import io.restassured.config.LogConfig; import io.restassured.config.RestAssuredConfig; import io.restassured.http.ContentType; -import io.restassured.specification.RequestSpecification; - -import java.nio.charset.StandardCharsets; - import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtensionContext; import org.junit.jupiter.params.ParameterizedTest; @@ -38,9 +34,11 @@ import org.junit.jupiter.params.provider.ArgumentsProvider; import org.junit.jupiter.params.provider.ArgumentsSource; +import java.nio.charset.StandardCharsets; import java.util.Collection; import java.util.List; import java.util.Map; +import java.util.function.Consumer; import java.util.stream.Collectors; import java.util.stream.Stream; @@ -50,21 +48,23 @@ class AttachmentArgumentProvider implements ArgumentsProvider { @Override - public Stream provideArguments(ExtensionContext context) { - + public Stream provideArguments(final ExtensionContext context) { return Stream.of( arguments(ImmutableList.of("Request", "HTTP/1.1 200 OK"), new AllureRestAssured()), - arguments(ImmutableList.of("Allure Request", "Allure Response"), new AllureRestAssured().setRequestAttachmentName("Allure Request").setResponseAttachmentName("Allure Response")), - arguments(ImmutableList.of("Request", "Allure Response"), new AllureRestAssured().setResponseAttachmentName("Allure Response")), - arguments(ImmutableList.of("Allure Request", "HTTP/1.1 200 OK"), new AllureRestAssured().setRequestAttachmentName("Allure Request")) + arguments(ImmutableList.of("Allure Request", "Allure Response"), new AllureRestAssured() + .setRequestAttachmentName("Allure Request") + .setResponseAttachmentName("Allure Response")), + arguments(ImmutableList.of("Request", "Allure Response"), new AllureRestAssured() + .setResponseAttachmentName("Allure Response")), + arguments(ImmutableList.of("Allure Request", "HTTP/1.1 200 OK"), new AllureRestAssured() + .setRequestAttachmentName("Allure Request")) ); } } class JsonPrettifyingArgumentsProvider implements ArgumentsProvider { @Override - public Stream provideArguments(ExtensionContext context) { - + public Stream provideArguments(final ExtensionContext context) { return Stream.of( arguments(new AllureRestAssured(), """ { @@ -80,8 +80,7 @@ public Stream provideArguments(ExtensionContext context) { class HiddenHeadersArgumentProvider implements ArgumentsProvider { @Override - public Stream provideArguments(ExtensionContext context) { - + public Stream provideArguments(final ExtensionContext context) { final String hiddenHeader = "Authorization"; final String header = "Accept"; final String headerValue = "value"; @@ -104,8 +103,15 @@ class AllureRestAssuredTest { @ParameterizedTest @ArgumentsSource(AttachmentArgumentProvider.class) void shouldCreateAttachment(final List attachmentNames, final AllureRestAssured filter) { - RestAssured.replaceFiltersWith(filter); - final AllureResults results = execute(); + final AllureResults results = executeWithStub( + server -> WireMock.stubFor(WireMock.get(WireMock.urlEqualTo("/hello?Allure=Form")) + .willReturn(WireMock.aResponse().withStatus(200).withBody("some body"))), + server -> RestAssured.given() + .contentType(ContentType.URLENC) + .formParams("Allure", "Form") + .get(server.url("/hello")).then().statusCode(200), + filter + ); assertThat(results.getTestResults() .stream() @@ -127,11 +133,26 @@ void shouldProperlySetAttachmentNameForSingleFilterInstance() { .withStatus(400) .withBody("some other body"); - RestAssured.replaceFiltersWith(filter); - final AllureResults resultsOne = executeWithStub(responseBuilderOne); + // Reuse the same filter instance for both requests to verify names are not cached. + final AllureResults resultsOne = executeWithStub( + server -> WireMock.stubFor(WireMock.get(WireMock.urlEqualTo("/hello?Allure=Form")) + .willReturn(responseBuilderOne)), + server -> RestAssured.given() + .contentType(ContentType.URLENC) + .formParams("Allure", "Form") + .get(server.url("/hello")).then().statusCode(200), + filter + ); - RestAssured.replaceFiltersWith(filter); - final AllureResults resultsTwo = executeWithStub(responseBuilderTwo); + final AllureResults resultsTwo = executeWithStub( + server -> WireMock.stubFor(WireMock.get(WireMock.urlEqualTo("/hello?Allure=Form")) + .willReturn(responseBuilderTwo)), + server -> RestAssured.given() + .contentType(ContentType.URLENC) + .formParams("Allure", "Form") + .get(server.url("/hello")).then().statusCode(400), + filter + ); assertThat(resultsOne.getTestResults() .stream() @@ -153,10 +174,17 @@ void shouldProperlySetAttachmentNameForSingleFilterInstance() { @ParameterizedTest @ArgumentsSource(AttachmentArgumentProvider.class) void shouldCatchAttachmentBody(final List attachmentNames, final AllureRestAssured filter) { - RestAssured.replaceFiltersWith(filter); - final AllureResults results = execute(); + final AllureResults results = executeWithStub( + server -> WireMock.stubFor(WireMock.get(WireMock.urlEqualTo("/hello?Allure=Form")) + .willReturn(WireMock.aResponse().withStatus(200).withBody("some body"))), + server -> RestAssured.given() + .contentType(ContentType.URLENC) + .formParams("Allure", "Form") + .get(server.url("/hello")).then().statusCode(200), + filter + ); - List actualAttachments = results.getTestResults().stream() + final List actualAttachments = results.getTestResults().stream() .map(TestResult::getAttachments) .flatMap(List::stream) .collect(Collectors.toList()); @@ -178,14 +206,21 @@ void shouldHideHeadersInAttachments(final Map headers, final String hiddenHeader, final List expectedValues, final AllureRestAssured filter) { - final ResponseDefinitionBuilder responseBuilder = WireMock.aResponse().withStatus(200); headers.forEach(responseBuilder::withHeader); RestAssured.config = new RestAssuredConfig().logConfig(LogConfig.logConfig().blacklistHeaders(List.of(hiddenHeader))); - RestAssured.replaceFiltersWith(filter); - final AllureResults results = executeWithStub(responseBuilder, RestAssured.with().headers(headers)); + final AllureResults results = executeWithStub( + server -> WireMock.stubFor(WireMock.get(WireMock.urlEqualTo("/hello?Allure=Form")) + .willReturn(responseBuilder)), + server -> RestAssured.with() + .headers(headers) + .contentType(ContentType.URLENC) + .formParams("Allure", "Form") + .get(server.url("/hello")).then().statusCode(200), + filter + ); assertThat(results.getAttachments().values()) .hasSize(2) @@ -203,9 +238,16 @@ void responseJsonPrettified(final AllureRestAssured filter, final String formatt """) .withStatus(200); - RestAssured.replaceFiltersWith(filter); + final AllureResults results = executeWithStub( + server -> WireMock.stubFor(WireMock.get(WireMock.urlEqualTo("/hello?Allure=Form")) + .willReturn(responseBuilder)), + server -> RestAssured.with() + .contentType(ContentType.URLENC) + .formParams("Allure", "Form") + .get(server.url("/hello")).then().statusCode(200), + filter + ); - final AllureResults results = executeWithStub(responseBuilder, RestAssured.with()); final Attachment requestAttachment = results.getTestResults() .stream() .map(TestResult::getAttachments) @@ -221,27 +263,62 @@ void responseJsonPrettified(final AllureRestAssured filter, final String formatt .contains(formattedBody); } - protected final AllureResults execute() { - return executeWithStub(WireMock.aResponse().withBody("some body")); + @Test + void shouldRenderListValuedFormParams() { + final ResponseDefinitionBuilder responseBuilder = WireMock.aResponse() + .withStatus(200) + .withBody("some body"); + + final AllureResults results = executeWithStub( + server -> WireMock.stubFor(WireMock.post(WireMock.urlPathEqualTo("/hello")) + .willReturn(responseBuilder)), + server -> RestAssured.given() + .contentType(ContentType.URLENC) + .formParam("data", List.of("a", "b")) + .post(server.url("/hello")).then().statusCode(200) + ); + + assertThat(results.getTestResults() + .stream() + .map(TestResult::getAttachments) + .flatMap(Collection::stream) + .map(Attachment::getName)) + .containsExactly("Request", "HTTP/1.1 200 OK"); + + final Attachment requestAttachment = results.getTestResults() + .stream() + .map(TestResult::getAttachments) + .flatMap(Collection::stream) + .filter(attachment -> "Request".equals(attachment.getName())) + .findAny() + .orElseThrow(() -> new AssertionError("No request attachment found")); + + final byte[] attachmentBody = results.getAttachments().get(requestAttachment.getSource()); + final String attachmentBodyString = new String(attachmentBody, StandardCharsets.UTF_8); + + assertThat(attachmentBodyString) + .contains("data: [a, b]") + .contains("data=[a, b]"); } - protected final AllureResults executeWithStub(ResponseDefinitionBuilder responseBuilder) { - return executeWithStub(responseBuilder, RestAssured.given()); + protected final AllureResults executeWithStub(final Consumer stubSetup, + final Consumer requestExecutor) { + return executeWithStub(stubSetup, requestExecutor, new AllureRestAssured()); } - protected final AllureResults executeWithStub(ResponseDefinitionBuilder responseBuilder, RequestSpecification spec) { + protected final AllureResults executeWithStub(final Consumer stubSetup, + final Consumer requestExecutor, + final AllureRestAssured filter) { final WireMockServer server = new WireMockServer(WireMockConfiguration.options().dynamicPort()); - final int statusCode = responseBuilder.build().getStatus(); return runWithinTestContext(() -> { server.start(); WireMock.configureFor(server.port()); + RestAssured.replaceFiltersWith(filter); - WireMock.stubFor(WireMock.get(WireMock.urlEqualTo("/hello?Allure=Form")).willReturn(responseBuilder)); + stubSetup.accept(server); try { - spec.contentType(ContentType.URLENC) - .formParams("Allure", "Form") - .get(server.url("/hello")).then().statusCode(statusCode); + requestExecutor.accept(server); } finally { server.stop(); RestAssured.replaceFiltersWith(ImmutableList.of()); From d7733b118195eaa589b3638c4d8fe917141a365f Mon Sep 17 00:00:00 2001 From: Dmitry Baev Date: Wed, 1 Apr 2026 17:28:56 +0100 Subject: [PATCH 09/13] render javadoc descriptions safely and ignore block tags (fixes #1039, via #1258) --- .../JavaDocDescriptionRenderer.java | 562 ++++++++++++++++++ .../JavaDocDescriptionsProcessor.java | 5 +- .../JavaDocDescriptionRendererTest.java | 328 ++++++++++ .../description/ProcessDescriptionsTest.java | 104 ++++ .../java/io/qameta/allure/Description.java | 3 +- .../io/qameta/allure/util/ResultsUtils.java | 18 +- .../AllureJunitPlatformTest.java | 6 +- .../features/DescriptionJavadocTest.java | 4 +- .../junit4/samples/DescriptionsJavadoc.java | 4 +- .../allure/testng/AllureTestNgTest.java | 36 +- 10 files changed, 1044 insertions(+), 26 deletions(-) create mode 100644 allure-descriptions-javadoc/src/main/java/io/qameta/allure/description/JavaDocDescriptionRenderer.java create mode 100644 allure-descriptions-javadoc/src/test/java/io/qameta/allure/description/JavaDocDescriptionRendererTest.java diff --git a/allure-descriptions-javadoc/src/main/java/io/qameta/allure/description/JavaDocDescriptionRenderer.java b/allure-descriptions-javadoc/src/main/java/io/qameta/allure/description/JavaDocDescriptionRenderer.java new file mode 100644 index 00000000..6546dbb4 --- /dev/null +++ b/allure-descriptions-javadoc/src/main/java/io/qameta/allure/description/JavaDocDescriptionRenderer.java @@ -0,0 +1,562 @@ +/* + * Copyright 2016-2026 Qameta Software Inc + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.qameta.allure.description; + +import java.util.Locale; + +/** + * Renders raw JavaDoc comment text into a safe, markdown-friendly description for Allure. + * + *

This renderer intentionally implements a small, conservative subset of the JavaDoc comment + * specification instead of attempting to preserve the full doclet output. The goal is to keep + * JavaDoc-backed descriptions readable in reports while ensuring that untrusted comment content is + * never treated as executable HTML.

+ * + *

The rendering algorithm is intentionally simple:

+ *
    + *
  1. Take only the main description, stopping at the first block tag such as {@code @param} + * or {@code @throws}.
  2. + *
  3. Render the remaining content with a small parser that recognizes a limited set of inline + * JavaDoc tags and structural HTML tags.
  4. + *
  5. Escape or drop everything else so the output remains plain text or safe markdown.
  6. + *
+ * + *

Currently supported JavaDoc constructs include inline tags such as {@code {@code ...}}, + * {@code {@literal ...}}, {@code {@link ...}}, and {@code {@linkplain ...}}.

+ * + *

The renderer also understands a small set of structural HTML tags: {@code p}, {@code br}, + * {@code ul}, {@code ol}, {@code li}, and {@code code}. Common entity references such as + * {@code &lt;}, {@code &gt;}, {@code &amp;}, {@code &#064;}, + * {@code &lbrace;}, and numeric entities are decoded before the output is escaped again.

+ * + *

Unsupported tags are degraded to escaped text instead of being interpreted. Unknown HTML tags + * are ignored as markup while their text content remains visible. This keeps the JavaDoc + * description path suitable for open source projects where comments may evolve over time and where + * security is more important than pixel-perfect parity with the standard doclet.

+ */ +final class JavaDocDescriptionRenderer { + + private static final String PARAGRAPH_BREAK = "\n\n"; + private static final String HTML_LT = "<"; + private static final String HTML_GT = ">"; + private static final String HTML_AMP = "&"; + private static final String INLINE_CODE_MARKER = "`"; + private static final String ESCAPED_INLINE_CODE_MARKER = "``"; + private static final String CODE_TAG = "code"; + private static final String HTML_TAG_END = ">"; + private static final String CLOSING_TAG_PREFIX = "The method extracts the JavaDoc main description, renders the supported inline and HTML + * constructs into plain text or markdown, normalizes whitespace, and escapes unsafe content. + * The returned value is intended for Allure's plain {@code description} field, not for + * {@code descriptionHtml}.

+ * + * @param rawDocComment the comment text returned by {@link javax.lang.model.util.Elements#getDocComment} + * @return a safe markdown/plain-text description, or an empty string if the comment has no main + * description + */ + String render(final String rawDocComment) { + final String descriptionBody = extractDescriptionBody(rawDocComment); + if (descriptionBody.isEmpty()) { + return ""; + } + + final StringBuilder rendered = new StringBuilder(); + renderFragment(descriptionBody, rendered); + return cleanup(rendered.toString()); + } + + private String extractDescriptionBody(final String rawDocComment) { + final String[] lines = normalize(rawDocComment).split("\n", -1); + final StringBuilder body = new StringBuilder(); + int inlineTagDepth = 0; + + for (String line : lines) { + if (inlineTagDepth == 0 && startsBlockTag(line)) { + return trimBlankLines(body.toString()); + } + if (body.length() > 0) { + body.append('\n'); + } + body.append(trimTrailingWhitespace(line)); + inlineTagDepth = updateInlineTagDepth(line, inlineTagDepth); + } + + return trimBlankLines(body.toString()); + } + + private boolean startsBlockTag(final String line) { + final String trimmed = line.trim(); + return trimmed.length() > 1 && trimmed.charAt(0) == '@' && Character.isJavaIdentifierStart(trimmed.charAt(1)); + } + + @SuppressWarnings("checkstyle:CyclomaticComplexity") + private void renderFragment(final String fragment, final StringBuilder rendered) { + int index = 0; + while (index < fragment.length()) { + final char current = fragment.charAt(index); + if (current == '{' && index + 1 < fragment.length() && fragment.charAt(index + 1) == '@') { + final int nextIndex = renderInlineTag(fragment, index, rendered); + if (nextIndex > index) { + index = nextIndex; + continue; + } + } + if (current == '<') { + final int nextIndex = renderHtmlTag(fragment, index, rendered); + if (nextIndex > index) { + index = nextIndex; + continue; + } + rendered.append(HTML_LT); + index++; + continue; + } + if (current == '&') { + final int nextIndex = renderEntityReference(fragment, index, rendered); + if (nextIndex > index) { + index = nextIndex; + continue; + } + rendered.append(HTML_AMP); + index++; + continue; + } + if (current == '>') { + rendered.append(HTML_GT); + index++; + continue; + } + rendered.append(current); + index++; + } + } + + @SuppressWarnings("checkstyle:ReturnCount") + private int renderInlineTag(final String fragment, final int start, final StringBuilder rendered) { + final int end = findInlineTagEnd(fragment, start); + if (end < 0) { + return start; + } + + final String content = fragment.substring(start + 2, end).trim(); + if (content.isEmpty()) { + return end + 1; + } + + final int separator = findWhitespace(content); + final String tag = separator < 0 ? content : content.substring(0, separator); + final String payload = separator < 0 ? "" : content.substring(separator + 1).trim(); + + if (CODE_TAG.equals(tag)) { + appendCode(rendered, payload); + return end + 1; + } + if ("literal".equals(tag)) { + rendered.append(escapeText(payload)); + return end + 1; + } + if ("link".equals(tag) || "linkplain".equals(tag)) { + appendLink(rendered, payload); + return end + 1; + } + + rendered.append(escapeText(content)); + return end + 1; + } + + @SuppressWarnings({ + "checkstyle:CyclomaticComplexity", + "checkstyle:NPathComplexity", + "checkstyle:ReturnCount"}) + private int renderHtmlTag(final String fragment, final int start, final StringBuilder rendered) { + if (start + 1 >= fragment.length() || Character.isWhitespace(fragment.charAt(start + 1))) { + return start; + } + + final int end = fragment.indexOf('>', start + 1); + if (end < 0) { + return start; + } + + final String rawTag = fragment.substring(start + 1, end).trim(); + if (rawTag.isEmpty()) { + return start; + } + + boolean closing = false; + String tag = rawTag; + if (tag.charAt(0) == '/') { + closing = true; + tag = tag.substring(1).trim(); + } + + if (tag.endsWith("/")) { + tag = tag.substring(0, tag.length() - 1).trim(); + } + + final int separator = findTagNameEnd(tag); + if (separator <= 0) { + return end + 1; + } + + final String name = tag.substring(0, separator).toLowerCase(Locale.ROOT); + if ("br".equals(name)) { + appendLineBreak(rendered); + return end + 1; + } + if ("p".equals(name) || "ul".equals(name) || "ol".equals(name)) { + appendParagraphBreak(rendered); + return end + 1; + } + if ("li".equals(name)) { + if (!closing) { + startListItem(rendered); + } + return end + 1; + } + if (CODE_TAG.equals(name)) { + if (closing) { + return end + 1; + } + final int closingStart = findClosingTag(fragment, end + 1, CODE_TAG); + if (closingStart <= end) { + return end + 1; + } + appendCode(rendered, fragment.substring(end + 1, closingStart)); + return closingStart + (CLOSING_TAG_PREFIX + CODE_TAG + HTML_TAG_END).length(); + } + + return end + 1; + } + + private void appendLink(final StringBuilder rendered, final String payload) { + if (payload.isEmpty()) { + return; + } + + final int separator = findWhitespace(payload); + final String label = separator < 0 ? "" : payload.substring(separator + 1).trim(); + if (label.isEmpty()) { + final String reference = separator < 0 ? payload : payload.substring(0, separator); + rendered.append(escapeText(shortenReference(reference))); + return; + } + + renderFragment(label, rendered); + } + + private String shortenReference(final String reference) { + final String trimmed = reference.trim(); + final int hashIndex = trimmed.lastIndexOf('#'); + if (hashIndex >= 0 && hashIndex + 1 < trimmed.length()) { + return trimmed.substring(hashIndex + 1); + } + + final int dotIndex = trimmed.lastIndexOf('.'); + if (dotIndex >= 0 && dotIndex + 1 < trimmed.length()) { + return trimmed.substring(dotIndex + 1); + } + + return trimmed; + } + + private void appendCode(final StringBuilder rendered, final String payload) { + final String escaped = escapeText(payload); + final String marker = escaped.contains(INLINE_CODE_MARKER) + ? ESCAPED_INLINE_CODE_MARKER + : INLINE_CODE_MARKER; + rendered.append(marker) + .append(escaped) + .append(marker); + } + + private void startListItem(final StringBuilder rendered) { + trimTrailingSpaces(rendered); + if (rendered.length() > 0 && rendered.charAt(rendered.length() - 1) != '\n') { + rendered.append('\n'); + } + rendered.append("- "); + } + + private void appendParagraphBreak(final StringBuilder rendered) { + trimTrailingSpaces(rendered); + if (rendered.length() == 0 || endsWith(rendered, PARAGRAPH_BREAK)) { + return; + } + if (rendered.charAt(rendered.length() - 1) == '\n') { + rendered.append('\n'); + return; + } + rendered.append(PARAGRAPH_BREAK); + } + + private void appendLineBreak(final StringBuilder rendered) { + trimTrailingSpaces(rendered); + if (rendered.length() == 0 || rendered.charAt(rendered.length() - 1) == '\n') { + return; + } + rendered.append('\n'); + } + + private String cleanup(final String rendered) { + final String[] lines = normalize(rendered).split("\n", -1); + final StringBuilder cleaned = new StringBuilder(); + boolean blankLinePending = false; + + for (String line : lines) { + final String trimmed = line.trim(); + if (trimmed.isEmpty()) { + if (cleaned.length() > 0) { + blankLinePending = true; + } + continue; + } + + if (cleaned.length() > 0) { + cleaned.append(blankLinePending ? PARAGRAPH_BREAK : "\n"); + } + cleaned.append(trimmed); + blankLinePending = false; + } + + return cleaned.toString(); + } + + private String trimBlankLines(final String value) { + final String[] lines = normalize(value).split("\n", -1); + int start = 0; + int end = lines.length; + + while (start < end && isBlank(lines[start])) { + start++; + } + while (end > start && isBlank(lines[end - 1])) { + end--; + } + + final StringBuilder result = new StringBuilder(); + for (int index = start; index < end; index++) { + if (result.length() > 0) { + result.append('\n'); + } + result.append(lines[index]); + } + return result.toString(); + } + + private int updateInlineTagDepth(final String line, final int initialDepth) { + int depth = initialDepth; + int index = 0; + while (index < line.length()) { + final char current = line.charAt(index); + if (depth == 0) { + if (current == '{' && index + 1 < line.length() && line.charAt(index + 1) == '@') { + depth = 1; + index += 2; + continue; + } + } else if (current == '{') { + depth++; + } else if (current == '}') { + depth--; + } + index++; + } + return depth; + } + + private void trimTrailingSpaces(final StringBuilder builder) { + while (builder.length() > 0) { + final char current = builder.charAt(builder.length() - 1); + if (current != ' ' && current != '\t') { + break; + } + builder.deleteCharAt(builder.length() - 1); + } + } + + private boolean endsWith(final StringBuilder builder, final String suffix) { + return builder.length() >= suffix.length() + && builder.substring(builder.length() - suffix.length()).equals(suffix); + } + + private int findWhitespace(final String value) { + for (int index = 0; index < value.length(); index++) { + if (Character.isWhitespace(value.charAt(index))) { + return index; + } + } + return -1; + } + + private int findTagNameEnd(final String tag) { + for (int index = 0; index < tag.length(); index++) { + final char current = tag.charAt(index); + if (!(Character.isLetterOrDigit(current) || current == '-' || current == '_')) { + return index; + } + } + return tag.length(); + } + + private int findClosingTag(final String fragment, final int fromIndex, final String tagName) { + return fragment.toLowerCase(Locale.ROOT).indexOf(CLOSING_TAG_PREFIX + tagName + HTML_TAG_END, fromIndex); + } + + private int findInlineTagEnd(final String fragment, final int start) { + int depth = 1; + for (int index = start + 2; index < fragment.length(); index++) { + final char current = fragment.charAt(index); + if (current == '{') { + depth++; + continue; + } + if (current == '}') { + depth--; + if (depth == 0) { + return index; + } + } + } + return -1; + } + + private String trimTrailingWhitespace(final String line) { + int end = line.length(); + while (end > 0) { + final char current = line.charAt(end - 1); + if (current != ' ' && current != '\t') { + break; + } + end--; + } + return line.substring(0, end); + } + + private String normalize(final String value) { + return value.replace("\r\n", "\n").replace('\r', '\n'); + } + + private boolean isBlank(final String value) { + for (int index = 0; index < value.length(); index++) { + if (!Character.isWhitespace(value.charAt(index))) { + return false; + } + } + return true; + } + + private int renderEntityReference(final String fragment, final int start, final StringBuilder rendered) { + final int end = fragment.indexOf(';', start + 1); + if (end < 0) { + return start; + } + + final String decoded = decodeEntity(fragment.substring(start + 1, end)); + if (decoded == null) { + return start; + } + + rendered.append(escapeText(decoded)); + return end + 1; + } + + @SuppressWarnings({ + "checkstyle:CyclomaticComplexity", + "checkstyle:NPathComplexity", + "checkstyle:ReturnCount"}) + private String decodeEntity(final String entity) { + if (entity.isEmpty()) { + return null; + } + + if (entity.charAt(0) == '#') { + return decodeNumericEntity(entity); + } + + if ("amp".equals(entity)) { + return Character.toString('&'); + } + if ("lt".equals(entity)) { + return Character.toString('<'); + } + if ("gt".equals(entity)) { + return Character.toString('>'); + } + if ("quot".equals(entity)) { + return "\""; + } + if ("apos".equals(entity)) { + return "'"; + } + if ("nbsp".equals(entity)) { + return " "; + } + if ("lbrace".equals(entity)) { + return "{"; + } + if ("rbrace".equals(entity)) { + return "}"; + } + if ("commat".equals(entity)) { + return Character.toString('@'); + } + return null; + } + + private String decodeNumericEntity(final String entity) { + try { + final int codePoint; + if (entity.startsWith("#x") || entity.startsWith("#X")) { + codePoint = Integer.parseInt(entity.substring(2), 16); + } else { + codePoint = Integer.parseInt(entity.substring(1), 10); + } + return new String(Character.toChars(codePoint)); + } catch (IllegalArgumentException e) { + return null; + } + } + + private String escapeText(final String value) { + final StringBuilder escaped = new StringBuilder(); + int index = 0; + while (index < value.length()) { + final char current = value.charAt(index); + if (current == '&') { + final int nextIndex = renderEntityReference(value, index, escaped); + if (nextIndex > index) { + index = nextIndex; + continue; + } + escaped.append(HTML_AMP); + } else if (current == '<') { + escaped.append(HTML_LT); + } else if (current == '>') { + escaped.append(HTML_GT); + } else { + escaped.append(current); + } + index++; + } + return escaped.toString(); + } +} diff --git a/allure-descriptions-javadoc/src/main/java/io/qameta/allure/description/JavaDocDescriptionsProcessor.java b/allure-descriptions-javadoc/src/main/java/io/qameta/allure/description/JavaDocDescriptionsProcessor.java index f423c7bb..acc1d555 100644 --- a/allure-descriptions-javadoc/src/main/java/io/qameta/allure/description/JavaDocDescriptionsProcessor.java +++ b/allure-descriptions-javadoc/src/main/java/io/qameta/allure/description/JavaDocDescriptionsProcessor.java @@ -54,6 +54,7 @@ public class JavaDocDescriptionsProcessor extends AbstractProcessor { private Filer filer; private Elements elementUtils; private Messager messager; + private JavaDocDescriptionRenderer renderer; @Override @SuppressWarnings("PMD.AvoidSynchronizedAtMethodLevel") @@ -62,6 +63,7 @@ public synchronized void init(final ProcessingEnvironment env) { filer = env.getFiler(); elementUtils = env.getElementUtils(); messager = env.getMessager(); + renderer = new JavaDocDescriptionRenderer(); } @Override @@ -76,12 +78,11 @@ public boolean process(final Set annotations, final Round final Set methods = ElementFilter.methodsIn(elements); methods.forEach(method -> { final String rawDocs = elementUtils.getDocComment(method); - if (rawDocs == null) { return; } - final String docs = rawDocs.trim(); + final String docs = renderer.render(rawDocs); if (docs.isEmpty()) { return; } diff --git a/allure-descriptions-javadoc/src/test/java/io/qameta/allure/description/JavaDocDescriptionRendererTest.java b/allure-descriptions-javadoc/src/test/java/io/qameta/allure/description/JavaDocDescriptionRendererTest.java new file mode 100644 index 00000000..180d2b03 --- /dev/null +++ b/allure-descriptions-javadoc/src/test/java/io/qameta/allure/description/JavaDocDescriptionRendererTest.java @@ -0,0 +1,328 @@ +/* + * Copyright 2016-2026 Qameta Software Inc + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.qameta.allure.description; + +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +class JavaDocDescriptionRendererTest { + + private final JavaDocDescriptionRenderer renderer = new JavaDocDescriptionRenderer(); + + @Test + void shouldRenderPlainTextAndTrimBlankLines() { + final String rendered = renderer.render( + "\r\n" + + " First line \r\n" + + "\r\n" + + " Second line\t\r\n" + + "\r\n" + ); + + assertThat(rendered) + .isEqualTo("First line\n\nSecond line"); + } + + @Test + void shouldReturnEmptyStringWhenBodyContainsOnlyBlockTags() { + final String rendered = renderer.render( + "@param value description\n" + + "@throws Exception description" + ); + + assertThat(rendered) + .isEmpty(); + } + + @Test + void shouldIgnoreBlockTagsAndEverythingAfterThem() { + final String rendered = renderer.render( + "Summary paragraph.\n" + + "\n" + + "@param value Description of the value.\n" + + "Continuation that should also be ignored." + ); + + assertThat(rendered) + .isEqualTo("Summary paragraph."); + } + + @Test + void shouldIgnoreStandardBlockTagsAfterMainDescription() { + final List blockTags = List.of( + "author", + "deprecated", + "exception", + "hidden", + "param", + "provides", + "return", + "see", + "serial", + "serialData", + "serialField", + "since", + "spec", + "throws", + "uses", + "version" + ); + + for (String blockTag : blockTags) { + assertThat(renderer.render("Summary paragraph.\n@" + blockTag + " metadata")) + .as(blockTag) + .isEqualTo("Summary paragraph."); + } + } + + @Test + void shouldNotTreatAtSignsInsideTextAsBlockTags() { + final String rendered = renderer.render( + "Email support@example.com\n" + + "Use @smoke in prose." + ); + + assertThat(rendered) + .isEqualTo("Email support@example.com\nUse @smoke in prose."); + } + + @Test + void shouldDecodeEscapedAtEntityBeforeBlockTags() { + final String rendered = renderer.render( + "@version stays in prose.\n" + + "@version 2.4.0" + ); + + assertThat(rendered) + .isEqualTo("@version stays in prose."); + } + + @Test + void shouldPreserveUnicodeCharactersInDescriptions() { + final String rendered = renderer.render("Release notes: cafe, café, Привет, 東京, λ."); + + assertThat(rendered) + .isEqualTo("Release notes: cafe, café, Привет, 東京, λ."); + } + + @Test + void shouldDecodeSupportedNamedAndNumericEntities() { + final String rendered = renderer.render( + "Use <tag>, &, {x}, @, λ, and λ." + ); + + assertThat(rendered) + .isEqualTo("Use <tag>, &, {x}, @, λ, and λ."); + } + + @Test + void shouldRenderSupportedInlineTags() { + final String rendered = renderer.render( + "Use {@code a < b}, {@literal }, " + + "{@link java.lang.String}, " + + "{@linkplain java.lang.String#valueOf(Object)}, " + + "{@link java.util.List list docs}." + ); + + assertThat(rendered) + .isEqualTo("Use `a < b`, <safe>, String, valueOf(Object), list docs."); + } + + @Test + void shouldSupportBalancedBracesInsideInlineTags() { + final String rendered = renderer.render( + "Payload {@code {\"outer\": {\"inner\": true}}}." + ); + + assertThat(rendered) + .isEqualTo("Payload `{\"outer\": {\"inner\": true}}`."); + } + + @Test + void shouldNotTreatAtLinesInsideBalancedInlineTagsAsBlockTags() { + final String rendered = renderer.render( + "Summary {@literal first line\n" + + "@notATag\n" + + "last line}\n" + + "@param ignored" + ); + + assertThat(rendered) + .isEqualTo("Summary first line\n@notATag\nlast line"); + } + + @Test + void shouldRenderNestedInlineTagsInsideLinkLabels() { + final String rendered = renderer.render( + "See {@linkplain java.util.List docs with {@code List}}." + ); + + assertThat(rendered) + .isEqualTo("See docs with `List`."); + } + + @Test + void shouldSafelyDegradeUnsupportedStandardInlineTags() { + final String rendered = renderer.render( + "Fallbacks: {@docRoot}, {@inheritDoc}, {@index release}, " + + "{@summary quick summary}, {@systemProperty user.home}, " + + "{@value java.lang.Integer#MAX_VALUE}." + ); + + assertThat(rendered) + .isEqualTo( + "Fallbacks: docRoot, inheritDoc, index release, summary quick summary, " + + "systemProperty user.home, value java.lang.Integer#MAX_VALUE." + ); + } + + @Test + void shouldSafelyDegradeSnippetTags() { + final String rendered = renderer.render( + "Snippet {@snippet :\n" + + "int answer = 42;\n" + + "@highlight substring=\"answer\"\n" + + "}." + ); + + assertThat(rendered) + .isEqualTo("Snippet snippet :\nint answer = 42;\n@highlight substring=\"answer\"."); + } + + @Test + void shouldEscapeUnknownInlineTags() { + final String rendered = renderer.render("Unsupported {@unknown } clause."); + + assertThat(rendered) + .isEqualTo("Unsupported unknown <tag> clause."); + } + + @Test + void shouldPreserveMalformedInlineTagsAsText() { + final String rendered = renderer.render("Broken {@code tag"); + + assertThat(rendered) + .isEqualTo("Broken {@code tag"); + } + + @Test + void shouldRenderSupportedHtmlStructure() { + final String rendered = renderer.render( + "First

Second
Third

  • one
  • two
  1. three
" + ); + + assertThat(rendered) + .isEqualTo("First\n\nSecond\nThird\n\n- one\n- two\n\n- three"); + } + + @Test + void shouldIgnoreUnclosedHtmlTagsSafely() { + final String rendered = renderer.render("Broken bold text"); + + assertThat(rendered) + .isEqualTo("Broken bold text"); + } + + @Test + void shouldPreserveAngleBracketComparisonsAsText() { + final String rendered = renderer.render("Math says a < b > c."); + + assertThat(rendered) + .isEqualTo("Math says a < b > c."); + } + + @Test + void shouldIgnoreUnmatchedCodeHtmlTagsSafely() { + final String rendered = renderer.render("Broken value < limit and stray tag"); + + assertThat(rendered) + .isEqualTo("Broken `value < limit and stray `tag"); + } + + @Test + void shouldIgnoreOpeningCodeTagWithoutClosingTag() { + final String rendered = renderer.render("Broken value < limit"); + + assertThat(rendered) + .isEqualTo("Broken value < limit"); + } + + @Test + void shouldIgnoreClosingCodeTagWithoutOpeningTag() { + final String rendered = renderer.render("Broken tag"); + + assertThat(rendered) + .isEqualTo("Broken tag"); + } + + @Test + void shouldRenderHtmlCodeTagAsCodeSpan() { + final String rendered = renderer.render("name < value & more"); + + assertThat(rendered) + .isEqualTo("`name < value & more`"); + } + + @Test + void shouldLeaveUnknownEntitiesEscaped() { + final String rendered = renderer.render("Keep ¬AnEntity; literal."); + + assertThat(rendered) + .isEqualTo("Keep &notAnEntity; literal."); + } + + @Test + void shouldDropUnknownHtmlTagsButKeepTheirTextContentEscaped() { + final String rendered = renderer.render( + "prefix
safe & sound
" + ); + + assertThat(rendered) + .isEqualTo("prefix alert(\"x\") safe & sound"); + } + + @Test + void shouldRenderComplexModernJavadocExampleSafely() { + final String rendered = renderer.render( + "Fetches release metadata for the current build.\n" + + "\n" + + "

Use {@link java.net.URI URIs} for endpoint configuration.

\n" + + "
    \n" + + "
  • Supports café, Привет, 東京, and λ.
  • \n" + + "
  • See the Javadoc specification " + + "and {@linkplain java.lang.String#formatted(Object...) formatted examples}.
  • \n" + + "
\n" + + "Example: client.fetch(\"v2\")\n" + + "@beta remains prose.\n" + + "@author Jane Doe\n" + + "@version 2.3.0\n" + + "@since 2.0" + ); + + assertThat(rendered) + .isEqualTo( + "Fetches release metadata for the current build.\n\n" + + "Use URIs for endpoint configuration.\n\n" + + "- Supports café, Привет, 東京, and λ.\n" + + "- See the Javadoc specification and formatted examples.\n\n" + + "Example: `client.fetch(\"v2\")`\n" + + "@beta remains prose." + ); + } +} diff --git a/allure-descriptions-javadoc/src/test/java/io/qameta/allure/description/ProcessDescriptionsTest.java b/allure-descriptions-javadoc/src/test/java/io/qameta/allure/description/ProcessDescriptionsTest.java index bf29d9f1..29f80277 100644 --- a/allure-descriptions-javadoc/src/test/java/io/qameta/allure/description/ProcessDescriptionsTest.java +++ b/allure-descriptions-javadoc/src/test/java/io/qameta/allure/description/ProcessDescriptionsTest.java @@ -194,4 +194,108 @@ void captureDescriptionParametrizedTestWithPrimitivesParameterTest() { .contentsAsUtf8String() .isEqualTo("Captured javadoc description"); } + + @Test + void shouldIgnoreBlockTagsAndRenderSafeMarkdown() { + final String expectedMethodSignatureHash = "4e7f896021ef2fce7c1deb7f5b9e38fb"; + + final JavaFileObject source = JavaFileObjects.forSourceLines( + "io.qameta.allure.description.test.DescriptionSample", + "package io.qameta.allure.description.test;", + "import io.qameta.allure.Description;", + "", + "public class DescriptionSample {", + "", + "/**", + "* This is my test description with {@code sample} and {@literal }.", + "*", + "*

Use {@link java.lang.String String} for values.

", + "*
    ", + "*
  • first item
  • ", + "*
  • second item
  • ", + "*
", + "* ", + "*", + "* @throws Exception", + "* Thrown when the test unexpectedly fails.", + "*/", + "@Description", + "public void sampleTest() throws Exception {", + "}", + "}" + ); + + final Compiler compiler = javac().withProcessors(new JavaDocDescriptionsProcessor()) + .withOptions("-Werror"); + final Compilation compilation = compiler.compile(source); + assertThat(compilation) + .generatedFile( + StandardLocation.CLASS_OUTPUT, + "", + ALLURE_DESCRIPTIONS_FOLDER + expectedMethodSignatureHash + ) + .contentsAsUtf8String() + .isEqualTo( + "This is my test description with `sample` and <safe>.\n\n" + + "Use String for values.\n\n" + + "- first item\n" + + "- second item\n\n" + + "alert(\"xss\")" + ); + } + + @Test + void shouldCaptureComplexModernJavadocDescriptionSafely() { + final String expectedMethodSignatureHash = "4e7f896021ef2fce7c1deb7f5b9e38fb"; + + final JavaFileObject source = JavaFileObjects.forSourceLines( + "io.qameta.allure.description.test.DescriptionSample", + "package io.qameta.allure.description.test;", + "import io.qameta.allure.Description;", + "", + "public class DescriptionSample {", + "", + "/**", + "* Fetches release metadata for the current build.", + "*", + "*

Use {@link java.net.URI URIs} for endpoint configuration.

", + "*
    ", + "*
  • Supports café, Привет, 東京, and λ.
  • ", + "*
  • See the Javadoc specification", + "* and {@linkplain java.lang.String#formatted(Object...) formatted examples}.
  • ", + "*
", + "* Example: client.fetch(\"v2\")", + "* @beta remains prose.", + "*", + "* @author Jane Doe", + "* @version 2.3.0", + "* @since 2.0", + "* @see Javadoc spec", + "*/", + "@Description", + "public void sampleTest() {", + "}", + "}" + ); + + final Compiler compiler = javac().withProcessors(new JavaDocDescriptionsProcessor()) + .withOptions("-Werror"); + final Compilation compilation = compiler.compile(source); + assertThat(compilation) + .generatedFile( + StandardLocation.CLASS_OUTPUT, + "", + ALLURE_DESCRIPTIONS_FOLDER + expectedMethodSignatureHash + ) + .contentsAsUtf8String() + .isEqualTo( + "Fetches release metadata for the current build.\n\n" + + "Use URIs for endpoint configuration.\n\n" + + "- Supports café, Привет, 東京, and λ.\n" + + "- See the Javadoc specification\n" + + "and formatted examples.\n\n" + + "Example: `client.fetch(\"v2\")`\n" + + "@beta remains prose." + ); + } } diff --git a/allure-java-commons/src/main/java/io/qameta/allure/Description.java b/allure-java-commons/src/main/java/io/qameta/allure/Description.java index b349a7c0..7c879ead 100644 --- a/allure-java-commons/src/main/java/io/qameta/allure/Description.java +++ b/allure-java-commons/src/main/java/io/qameta/allure/Description.java @@ -35,8 +35,7 @@ String value() default ""; /** - * Use annotated method's javadoc to extract description that - * supports html markdown. + * Use annotated method's javadoc to extract a safe markdown/plain-text description. * * @return boolean flag to enable description extraction from javadoc. * @deprecated use {@link Description} without value specified instead. diff --git a/allure-java-commons/src/main/java/io/qameta/allure/util/ResultsUtils.java b/allure-java-commons/src/main/java/io/qameta/allure/util/ResultsUtils.java index 289df7df..d8d3b7f8 100644 --- a/allure-java-commons/src/main/java/io/qameta/allure/util/ResultsUtils.java +++ b/allure-java-commons/src/main/java/io/qameta/allure/util/ResultsUtils.java @@ -304,7 +304,7 @@ public static Optional getJavadocDescription(final ClassLoader classLoad parameterTypes); return readResource(classLoader, ALLURE_DESCRIPTIONS_FOLDER + signatureHash) - .map(desc -> separateLines() ? desc.replace("\n", "
") : desc); + .map(desc -> separateLines() ? toMarkdownLineBreaks(desc) : desc); } public static Optional firstNonEmpty(final String... items) { @@ -390,7 +390,7 @@ public static void processDescription(final ClassLoader classLoader, final Description annotation = method.getAnnotation(Description.class); if ("".equals(annotation.value())) { getJavadocDescription(classLoader, method) - .ifPresent(setDescriptionHtml); + .ifPresent(setDescription); } else { final String description = annotation.value(); setDescription.accept(description); @@ -398,6 +398,20 @@ public static void processDescription(final ClassLoader classLoader, } } + private static String toMarkdownLineBreaks(final String description) { + final String[] lines = description.split("\n", -1); + final StringBuilder markdown = new StringBuilder(); + for (int index = 0; index < lines.length; index++) { + if (index > 0) { + final String previousLine = lines[index - 1]; + final String currentLine = lines[index]; + markdown.append(previousLine.isEmpty() || currentLine.isEmpty() ? "\n" : " \n"); + } + markdown.append(lines[index]); + } + return markdown.toString(); + } + private static Optional readResource(final ClassLoader classLoader, final String resourceName) { try (InputStream is = classLoader.getResourceAsStream(resourceName)) { if (Objects.isNull(is)) { diff --git a/allure-junit-platform/src/test/java/io/qameta/allure/junitplatform/AllureJunitPlatformTest.java b/allure-junit-platform/src/test/java/io/qameta/allure/junitplatform/AllureJunitPlatformTest.java index ebb98067..29de4f12 100644 --- a/allure-junit-platform/src/test/java/io/qameta/allure/junitplatform/AllureJunitPlatformTest.java +++ b/allure-junit-platform/src/test/java/io/qameta/allure/junitplatform/AllureJunitPlatformTest.java @@ -65,7 +65,6 @@ import io.qameta.allure.test.AllureFeatures; import io.qameta.allure.test.AllureResults; import io.qameta.allure.test.RunUtils; -import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.parallel.ResourceLock; import org.junit.platform.engine.discovery.DiscoverySelectors; @@ -589,15 +588,14 @@ void shouldSetOwner() { } @AllureFeatures.Descriptions - @Disabled("Fails when run using IDEA") @Test void shouldSetJavadocDescription() { final AllureResults results = runClasses(DescriptionJavadocTest.class); final List testResults = results.getTestResults(); assertThat(testResults) - .extracting(TestResult::getDescriptionHtml) - .contains(" Test javadoc description.\n"); + .extracting(TestResult::getDescription, TestResult::getDescriptionHtml) + .containsExactly(tuple("Test javadoc description.", null)); } @AllureFeatures.Attachments diff --git a/allure-junit-platform/src/test/java/io/qameta/allure/junitplatform/features/DescriptionJavadocTest.java b/allure-junit-platform/src/test/java/io/qameta/allure/junitplatform/features/DescriptionJavadocTest.java index c34d8dc8..d0247b9d 100644 --- a/allure-junit-platform/src/test/java/io/qameta/allure/junitplatform/features/DescriptionJavadocTest.java +++ b/allure-junit-platform/src/test/java/io/qameta/allure/junitplatform/features/DescriptionJavadocTest.java @@ -25,9 +25,11 @@ public class DescriptionJavadocTest { /** * Test javadoc description. + * + * @throws Exception if block tags are not filtered out. */ @Description(useJavaDoc = true) @Test - void testWithJavadocDescription() { + void testWithJavadocDescription() throws Exception { } } diff --git a/allure-junit4/src/test/java/io/qameta/allure/junit4/samples/DescriptionsJavadoc.java b/allure-junit4/src/test/java/io/qameta/allure/junit4/samples/DescriptionsJavadoc.java index 555632fb..0fbb5f9c 100644 --- a/allure-junit4/src/test/java/io/qameta/allure/junit4/samples/DescriptionsJavadoc.java +++ b/allure-junit4/src/test/java/io/qameta/allure/junit4/samples/DescriptionsJavadoc.java @@ -25,9 +25,11 @@ public class DescriptionsJavadoc { /** * Description from javadoc. + * + * @throws Exception if block tags are not filtered out. */ @Description @Test - public void simpleTest() { + public void simpleTest() throws Exception { } } diff --git a/allure-testng/src/test/java/io/qameta/allure/testng/AllureTestNgTest.java b/allure-testng/src/test/java/io/qameta/allure/testng/AllureTestNgTest.java index 0148ad0d..bc1f54bf 100644 --- a/allure-testng/src/test/java/io/qameta/allure/testng/AllureTestNgTest.java +++ b/allure-testng/src/test/java/io/qameta/allure/testng/AllureTestNgTest.java @@ -201,16 +201,16 @@ public void descriptionsWithLineSeparationTest() { System.setProperty(ALLURE_SEPARATE_LINES_SYSPROP, "true"); } try { - final String testDescription = "Sample test description
- next line
- another line"; + final String testDescription = "Sample test description \n- next line \n- another line"; final AllureResults results = runTestNgSuites("suites/descriptions-test.xml"); List testResult = results.getTestResults(); assertThat(testResult).as("Test case result has not been written") .hasSize(2) .filteredOn(result -> result.getName().equals("testSeparated")) - .extracting(result -> result.getDescriptionHtml().trim()) + .extracting(TestResult::getDescription, TestResult::getDescriptionHtml) .as("Javadoc description of test case has not been processed correctly") - .contains(testDescription); + .contains(tuple(testDescription, null)); } finally { System.setProperty(ALLURE_SEPARATE_LINES_SYSPROP, String.valueOf(initialSeparateLines)); } @@ -226,10 +226,9 @@ public void descriptionsTest() { assertThat(testResult).as("Test case result has not been written") .hasSize(2) .filteredOn(result -> result.getName().equals("test")) - .extracting(TestResult::getDescriptionHtml) - .map(String::trim) + .extracting(TestResult::getDescription, TestResult::getDescriptionHtml) .as("Javadoc description of test case has not been processed") - .contains(testDescription); + .contains(tuple(testDescription, null)); } @AllureFeatures.Descriptions @@ -246,9 +245,15 @@ public void descriptionsBefores(final XmlSuite.ParallelMode mode, final int thre assertThat(testContainers).as("Test containers has not been written") .isNotEmpty() .filteredOn(container -> !container.getBefores().isEmpty()) - .extracting(container -> container.getBefores().get(0).getDescriptionHtml().trim()) + .extracting( + container -> container.getBefores().get(0).getDescription(), + container -> container.getBefores().get(0).getDescriptionHtml() + ) .as("Javadoc description of befores have not been processed") - .containsOnly(beforeClassDescription, beforeMethodDescription); + .containsOnly( + tuple(beforeClassDescription, null), + tuple(beforeMethodDescription, null) + ); } @AllureFeatures.Descriptions @@ -1382,24 +1387,27 @@ private static void assertBeforeFixtures(String containerName, List containers, String methodReference, String expectedDescriptionHtml) { + private static void checkBeforeJavadocDescriptions(List containers, String methodReference, String expectedDescription) { assertThat(containers).as("Test containers has not been written") .isNotEmpty() .filteredOn(container -> !container.getBefores().isEmpty()) .filteredOn(container -> container.getName().equals(methodReference)) - .extracting(container -> container.getBefores().get(0).getDescriptionHtml().trim()) + .extracting( + container -> container.getBefores().get(0).getDescription(), + container -> container.getBefores().get(0).getDescriptionHtml() + ) .as("Javadoc description of befores have been processed incorrectly") - .containsOnly(expectedDescriptionHtml); + .containsOnly(tuple(expectedDescription, null)); } @Step("Check that javadoc description of tests refer to correct test methods") - private static void checkTestJavadocDescriptions(List results, String methodReference, String expectedDescriptionHtml) { + private static void checkTestJavadocDescriptions(List results, String methodReference, String expectedDescription) { assertThat(results).as("Test results has not been written") .isNotEmpty() .filteredOn(result -> result.getFullName().equals(methodReference)) - .extracting(result -> result.getDescriptionHtml().trim()) + .extracting(TestResult::getDescription, TestResult::getDescriptionHtml) .as("Javadoc description of befores have been processed incorrectly") - .containsOnly(expectedDescriptionHtml); + .containsOnly(tuple(expectedDescription, null)); } private final TestPlanV1_0.TestCase onlyId2 = new TestPlanV1_0.TestCase().setId("2"); From db7274478b6cbd09e489fb5153ca68ed2278364b Mon Sep 17 00:00:00 2001 From: Dmitry Baev Date: Wed, 1 Apr 2026 18:18:28 +0100 Subject: [PATCH 10/13] chore(allure-restassured): add test to verify null param issue (fixes #1167, via #1259) Co-authored-by: Dmitry Baev --- .../restassured/AllureRestAssuredTest.java | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/allure-rest-assured/src/test/java/io/qameta/allure/restassured/AllureRestAssuredTest.java b/allure-rest-assured/src/test/java/io/qameta/allure/restassured/AllureRestAssuredTest.java index 2210b7dc..c35cef0d 100644 --- a/allure-rest-assured/src/test/java/io/qameta/allure/restassured/AllureRestAssuredTest.java +++ b/allure-rest-assured/src/test/java/io/qameta/allure/restassured/AllureRestAssuredTest.java @@ -36,6 +36,7 @@ import java.nio.charset.StandardCharsets; import java.util.Collection; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.function.Consumer; @@ -301,6 +302,33 @@ void shouldRenderListValuedFormParams() { .contains("data=[a, b]"); } + @Test + void shouldNotFailForNullValuedFormParamsMap() { + final ResponseDefinitionBuilder responseBuilder = WireMock.aResponse() + .withStatus(200) + .withBody("some body"); + + final Map formParams = new LinkedHashMap<>(); + formParams.put("param1", "value1"); + formParams.put("param2", null); + + final AllureResults results = executeWithStub( + server -> WireMock.stubFor(WireMock.post(WireMock.urlPathEqualTo("/hello")) + .willReturn(responseBuilder)), + server -> RestAssured.given() + .contentType(ContentType.URLENC) + .formParams(formParams) + .post(server.url("/hello")).then().statusCode(200) + ); + + assertThat(results.getTestResults() + .stream() + .map(TestResult::getAttachments) + .flatMap(Collection::stream) + .map(Attachment::getName)) + .containsExactly("Request", "HTTP/1.1 200 OK"); + } + protected final AllureResults executeWithStub(final Consumer stubSetup, final Consumer requestExecutor) { return executeWithStub(stubSetup, requestExecutor, new AllureRestAssured()); From 822cb2c535213295fc58fe9d9d680f498b99044c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 1 Apr 2026 18:19:19 +0100 Subject: [PATCH 11/13] build(deps): bump gradle/actions from 5 to 6 (via #1252) --- .github/workflows/dependency-submission.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/dependency-submission.yml b/.github/workflows/dependency-submission.yml index 748dfe67..d02a1dbe 100644 --- a/.github/workflows/dependency-submission.yml +++ b/.github/workflows/dependency-submission.yml @@ -16,7 +16,7 @@ jobs: - name: Checkout sources uses: actions/checkout@v6 - name: Generate and submit dependency graph - uses: gradle/actions/dependency-submission@v5 + uses: gradle/actions/dependency-submission@v6 env: DEPENDENCY_GRAPH_EXCLUDE_PROJECTS: ':allure-java-commons-test' DEPENDENCY_GRAPH_INCLUDE_CONFIGURATIONS: 'runtimeClasspath' From 1f5fab882bcebfb2a25b44b54fd191d23c84c746 Mon Sep 17 00:00:00 2001 From: Dmitry Baev Date: Wed, 8 Apr 2026 12:00:51 +0100 Subject: [PATCH 12/13] bump restassured to 6 (via #1260) --- allure-rest-assured/build.gradle.kts | 6 +++++- .../io/qameta/allure/restassured/AllureRestAssured.java | 2 +- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/allure-rest-assured/build.gradle.kts b/allure-rest-assured/build.gradle.kts index 4834fa00..0f88a16d 100644 --- a/allure-rest-assured/build.gradle.kts +++ b/allure-rest-assured/build.gradle.kts @@ -1,6 +1,6 @@ description = "Allure Rest-Assured Integration" -val restAssuredVersion = "5.5.6" +val restAssuredVersion = "6.0.0" dependencies { api(project(":allure-attachments")) @@ -29,3 +29,7 @@ tasks.jar { tasks.test { useJUnitPlatform() } + +tasks.compileJava { + options.release.set(17) +} diff --git a/allure-rest-assured/src/main/java/io/qameta/allure/restassured/AllureRestAssured.java b/allure-rest-assured/src/main/java/io/qameta/allure/restassured/AllureRestAssured.java index d59e998a..51c166ae 100644 --- a/allure-rest-assured/src/main/java/io/qameta/allure/restassured/AllureRestAssured.java +++ b/allure-rest-assured/src/main/java/io/qameta/allure/restassured/AllureRestAssured.java @@ -150,7 +150,7 @@ public Response filter(final FilterableRequestSpecification requestSpec, } private static Map toMapConverter(final Iterable items, - final Set toHide) { + final Set toHide) { final Map result = new HashMap<>(); items.forEach(h -> result.put(h.getName(), toHide.contains(h.getName()) ? HIDDEN_PLACEHOLDER : h.getValue())); return result; From 60c3bd11f65318574d3430aea92716e2f946f7c6 Mon Sep 17 00:00:00 2001 From: qameta-ci Date: Thu, 9 Apr 2026 09:25:33 +0000 Subject: [PATCH 13/13] release 2.34.0 --- gradle.properties | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle.properties b/gradle.properties index ffccaed3..b41179d6 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,4 +1,4 @@ -version=2.34-SNAPSHOT +version=2.34.0 org.gradle.daemon=true org.gradle.parallel=true