From 7d2bf3672f8a81ac61904248f7abf35b247672a8 Mon Sep 17 00:00:00 2001 From: Dmitry Baev Date: Fri, 3 Jul 2026 08:28:36 +0100 Subject: [PATCH 1/2] new lifecycle --- .idea/vcs.xml | 3 + AGENTS.md | 6 +- .../qameta/allure/assertj/AllureAspectJ.java | 22 +- .../qameta/allure/assertj/AssertJChain.java | 10 +- .../allure/assertj/AssertJRecorder.java | 6 +- .../allure/assertj/AllureAspectJTest.java | 38 +- .../awaitility/AllureAwaitilityListener.java | 165 +- .../ConditionListenersPositiveTest.java | 10 +- .../GlobalSettingsNegativeTest.java | 5 +- .../GlobalSettingsPositiveTest.java | 10 +- .../awaitility/MultipleConditionsTest.java | 5 +- .../io/qameta/allure/citrus/AllureCitrus.java | 59 +- .../allure/citrus/AllureCitrusTest.java | 88 +- .../cucumber7jvm/AllureCucumber7Jvm.java | 137 +- .../testsourcemodel/TestSourcesModel.java | 3 + .../cucumber7jvm/AllureCucumber7JvmTest.java | 15 +- .../cucumber7jvm/samples/RuntimeApiSteps.java | 20 +- .../test/resources/features/datatable.feature | 2 +- .../io/qameta/allure/grpc/AllureGrpc.java | 72 +- .../io/qameta/allure/grpc/AllureGrpcTest.java | 2 + allure-hamcrest/build.gradle.kts | 1 + .../allure/hamcrest/AllureHamcrestAssert.java | 31 +- ...mcrestAssertionNameContainsReasonTest.java | 8 +- ...AllureHamcrestCollectionsMatchersTest.java | 11 +- .../AllureHamcrestLogicalMatchersTest.java | 5 +- .../hamcrest/AllureHamcrestNoContextTest.java | 42 + .../AllureHamcrestNumberMatchersTest.java | 5 +- .../AllureHamcrestObjectMatchersTest.java | 5 +- .../AllureHamcrestTextMatchersTest.java | 5 +- .../httpclient/AllureHttpClientRequest.java | 6 + .../httpclient/AllureHttpClientResponse.java | 5 + .../httpclient/AllureHttpClientTest.java | 2 + .../httpclient5/AllureHttpClient5Request.java | 6 + .../AllureHttpClient5Response.java | 5 + .../httpclient5/HttpExchangeTestSupport.java | 2 + allure-java-commons-test/build.gradle.kts | 2 +- .../allure/test/AllureTestCommonsUtils.java | 22 +- .../qameta/allure/test/IsolatedLifecycle.java | 43 + .../java/io/qameta/allure/test/RunUtils.java | 108 +- .../io/qameta/allure/test/RunUtilsTest.java | 4 +- .../qameta/allure/test/TestUtilitiesTest.java | 2 +- allure-java-commons/README.md | 4 +- .../main/java/io/qameta/allure/Allure.java | 319 +-- .../io/qameta/allure/AllureExternalKey.java | 144 ++ .../io/qameta/allure/AllureLifecycle.java | 1729 +++++++++-------- .../io/qameta/allure/AllureThreadBinding.java | 26 + .../io/qameta/allure/AttachmentOptions.java | 70 + .../allure/FileSystemResultsWriter.java | 3 + .../allure/aspects/AttachmentsAspects.java | 35 +- .../qameta/allure/aspects/StepsAspects.java | 50 +- .../allure/http/HttpExchangeProcessor.java | 6 + .../internal/AllureExecutionContext.java | 140 ++ .../qameta/allure/internal/AllureStorage.java | 133 -- .../allure/internal/AllureThreadContext.java | 127 +- .../allure/listener/LifecycleNotifier.java | 42 +- .../util/WellKnownFileExtensionsUtils.java | 978 ++++++++++ .../qameta/allure/AllureExternalKeyTest.java | 77 + .../allure/AllureLifecycleAmbientTest.java | 172 ++ .../io/qameta/allure/AllureLifecycleTest.java | 804 +++++--- .../io/qameta/allure/AllureStageTest.java | 169 ++ .../java/io/qameta/allure/AllureTest.java | 139 +- .../allure/StepLifecycleListenerTest.java | 46 +- .../allure/aspects/StepsAspectsTest.java | 20 + .../internal/AllureThreadContextTest.java | 301 ++- .../testfilter/FileTestPlanSupplierTest.java | 4 +- .../allure/util/ParameterUtilsTest.java | 4 +- .../WellKnownFileExtensionsUtilsTest.java | 52 + .../io/qameta/allure/jaxrs/AllureJaxRs.java | 8 + .../allure/httpclient/AllureJaxRsTest.java | 2 + .../allure/jbehave5/AllureJbehave5.java | 52 +- .../allure/jbehave5/AllureJbehave5Test.java | 2 + .../jbehave5/samples/RuntimeApiSteps.java | 2 +- .../io/qameta/allure/jooq/AllureJooq.java | 32 +- .../io/qameta/allure/jooq/AllureJooqTest.java | 8 +- .../allure/jsonunit/JsonPatchMatcher.java | 11 +- .../junitplatform/AllureJunitPlatform.java | 123 +- .../AllureJunitPlatformTest.java | 2 + .../AllureJunitPlatformTestUtils.java | 2 + .../junitplatform/features/TestWithSteps.java | 8 +- .../io/qameta/allure/junit4/AllureJunit4.java | 56 +- .../allure/junit4/AllureJunit4Test.java | 2 + .../jupiterassert/AllureJupiterAssert.java | 36 +- .../AllureJupiterAssertTest.java | 5 +- .../AllureJupiterJunit6CompatibilityTest.java | 2 + allure-karate/build.gradle.kts | 1 + .../io/qameta/allure/karate/AllureKarate.java | 42 +- .../io/qameta/allure/karate/TestRunner.java | 2 + allure-model/build.gradle.kts | 1 + .../io/qameta/allure/model/ScopeResult.java | 69 +- .../io/qameta/allure/model/TestResult.java | 2 +- .../io/qameta/allure/model/WithMetadata.java | 69 + .../qameta/allure/okhttp3/AllureOkHttp3.java | 5 + .../allure/okhttp3/AllureOkHttp3Test.java | 31 + .../allure/playwright/AllurePlaywright.java | 20 +- .../playwright/AllurePlaywrightAspect.java | 42 +- .../playwright/AllurePlaywrightRegistry.java | 4 + .../playwright/AllurePlaywrightTest.java | 51 +- .../allure/restassured/AllureRestAssured.java | 5 + .../restassured/AllureRestAssuredTest.java | 5 + .../allure/scalatest/AllureScalatest.scala | 40 +- .../scalatest/AllureScalatestTest.scala | 38 +- .../allure/selenide/AllureSelenide.java | 42 +- .../allure/selenide/AllureSelenideTest.java | 2 + .../seleniumbidi/AllureWebDriverBiDi.java | 12 +- .../seleniumbidi/BiDiAttachmentStorage.java | 18 +- .../allure/seleniumbidi/BiDiSessionState.java | 8 +- .../SeleniumBiDiSessionFactory.java | 3 + .../seleniumbidi/AllureWebDriverBiDiTest.java | 2 + .../io/qameta/allure/spock2/AllureSpock2.java | 81 +- .../allure/spock2/AllureSpock2Test.java | 5 +- .../allure/springweb/AllureRestTemplate.java | 5 + .../springweb/AllureRestTemplateTest.java | 2 + .../io/qameta/allure/testng/AllureTestNg.java | 370 ++-- .../allure/testng/AllureTestNgTest.java | 318 +-- .../testng/samples/AttachmentsTest.java | 2 +- .../testng/samples/BeforeMethodMetadata.java | 36 + .../samples/DataProviderWithAttachment.java | 2 +- .../suites/before-method-metadata.xml | 11 + ...ure-test-agent.md => allure-agent-mode.md} | 64 +- 119 files changed, 5577 insertions(+), 2778 deletions(-) create mode 100644 allure-hamcrest/src/test/java/io/qameta/allure/hamcrest/AllureHamcrestNoContextTest.java create mode 100644 allure-java-commons-test/src/main/java/io/qameta/allure/test/IsolatedLifecycle.java create mode 100644 allure-java-commons/src/main/java/io/qameta/allure/AllureExternalKey.java create mode 100644 allure-java-commons/src/main/java/io/qameta/allure/AllureThreadBinding.java create mode 100644 allure-java-commons/src/main/java/io/qameta/allure/AttachmentOptions.java create mode 100644 allure-java-commons/src/main/java/io/qameta/allure/internal/AllureExecutionContext.java delete mode 100644 allure-java-commons/src/main/java/io/qameta/allure/internal/AllureStorage.java create mode 100644 allure-java-commons/src/main/java/io/qameta/allure/util/WellKnownFileExtensionsUtils.java create mode 100644 allure-java-commons/src/test/java/io/qameta/allure/AllureExternalKeyTest.java create mode 100644 allure-java-commons/src/test/java/io/qameta/allure/AllureLifecycleAmbientTest.java create mode 100644 allure-java-commons/src/test/java/io/qameta/allure/AllureStageTest.java create mode 100644 allure-java-commons/src/test/java/io/qameta/allure/util/WellKnownFileExtensionsUtilsTest.java create mode 100644 allure-model/src/main/java/io/qameta/allure/model/WithMetadata.java create mode 100644 allure-testng/src/test/java/io/qameta/allure/testng/samples/BeforeMethodMetadata.java create mode 100644 allure-testng/src/test/resources/suites/before-method-metadata.xml rename docs/{allure-test-agent.md => allure-agent-mode.md} (75%) diff --git a/.idea/vcs.xml b/.idea/vcs.xml index 95443a122..d154cb226 100644 --- a/.idea/vcs.xml +++ b/.idea/vcs.xml @@ -10,4 +10,7 @@ + + + \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md index 3d4d8db85..732e8f7b1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,10 +4,10 @@ Never create pull requests or push git branches without explicit confirmation fr ## Test Work -Use [Allure Test Agent](docs/allure-test-agent.md) for test-related work in this repository. +Use [Allure Agent Mode](docs/allure-agent-mode.md) for test-related work in this repository. -- Read `docs/allure-test-agent.md` before designing, writing, reviewing, validating, debugging, or enriching tests. -- Use the `$allure-test-agent` skill as the durable behavior guide when it is installed; this project file contains local commands and conventions. +- Read `docs/allure-agent-mode.md` before designing, writing, reviewing, validating, debugging, or enriching tests. +- Use the `$allure-agent-mode` skill as the durable behavior guide when it is installed; this project file contains local commands and conventions. - If a command executes tests and its result will be used for smoke checking, reasoning, review, coverage analysis, debugging, or a user-facing conclusion, run it through `allure agent`. - Use agent-mode execution for smoke checks too, even when the change is small or mechanical. - If agent output is missing or incomplete, debug that first and treat console-only conclusions as provisional. diff --git a/allure-assertj/src/main/java/io/qameta/allure/assertj/AllureAspectJ.java b/allure-assertj/src/main/java/io/qameta/allure/assertj/AllureAspectJ.java index 33e2a3de6..351b6c205 100644 --- a/allure-assertj/src/main/java/io/qameta/allure/assertj/AllureAspectJ.java +++ b/allure-assertj/src/main/java/io/qameta/allure/assertj/AllureAspectJ.java @@ -38,13 +38,6 @@ @Aspect public class AllureAspectJ { - private static InheritableThreadLocal lifecycle = new InheritableThreadLocal() { - @Override - protected AllureLifecycle initialValue() { - return Allure.getLifecycle(); - } - }; - private static final ThreadLocal RECORDER = ThreadLocal.withInitial(AssertJRecorder::new); private static final ThreadLocal RECORDING_MUTED = ThreadLocal.withInitial(() -> false); @@ -158,27 +151,18 @@ public void softAssertionFailed(final AssertionError error) { getRecorder().softAssertionFailed(error); } - /** - * For tests only. - * - * @param allure allure lifecycle to set. - */ - public static void setLifecycle(final AllureLifecycle allure) { - lifecycle.set(allure); - clearContext(); - } - /** * Returns the lifecycle. * * @return the Allure lifecycle used by this integration */ public static AllureLifecycle getLifecycle() { - return lifecycle.get(); + return Allure.getLifecycle(); } /** - * Handles the clear context callback. + * Drops the calling thread's recorder state. Invoked by {@link AssertJLifecycleListener} at test boundaries so + * chain bookkeeping never accumulates across tests. */ public static void clearContext() { RECORDER.remove(); diff --git a/allure-assertj/src/main/java/io/qameta/allure/assertj/AssertJChain.java b/allure-assertj/src/main/java/io/qameta/allure/assertj/AssertJChain.java index c4d30c752..21b850902 100644 --- a/allure-assertj/src/main/java/io/qameta/allure/assertj/AssertJChain.java +++ b/allure-assertj/src/main/java/io/qameta/allure/assertj/AssertJChain.java @@ -15,6 +15,7 @@ */ package io.qameta.allure.assertj; +import io.qameta.allure.AllureExternalKey; import io.qameta.allure.model.Stage; import io.qameta.allure.model.Status; import io.qameta.allure.model.StatusDetails; @@ -22,7 +23,6 @@ import org.assertj.core.api.AbstractAssert; import java.util.Optional; -import java.util.UUID; /** * Parent Allure step for one AssertJ assertion chain. @@ -69,14 +69,14 @@ final class AssertJChain { private static final String ASSERTJ_STEP_PREFIX = "assert "; - private final String uuid; + private final AllureExternalKey key; private final AbstractAssert assertion; private final StepResult step; AssertJChain(final AbstractAssert assertion, final String subject) { - this.uuid = UUID.randomUUID().toString(); + this.key = AllureExternalKey.random(AllureAspectJ.class); this.assertion = assertion; this.step = new StepResult() .setName(chainName(subject)) @@ -86,8 +86,8 @@ final class AssertJChain { .setStop(System.currentTimeMillis()); } - String getUuid() { - return uuid; + AllureExternalKey getKey() { + return key; } AbstractAssert getAssertion() { diff --git a/allure-assertj/src/main/java/io/qameta/allure/assertj/AssertJRecorder.java b/allure-assertj/src/main/java/io/qameta/allure/assertj/AssertJRecorder.java index 50bc77531..18794e577 100644 --- a/allure-assertj/src/main/java/io/qameta/allure/assertj/AssertJRecorder.java +++ b/allure-assertj/src/main/java/io/qameta/allure/assertj/AssertJRecorder.java @@ -187,8 +187,10 @@ private void attachChain(final AllureLifecycle lifecycle, final AssertJChain chain, final AssertJOperation parentOperation) { if (parentOperation == null) { - lifecycle.startStep(chain.getUuid(), chain.getStep()); - lifecycle.stopStep(chain.getUuid()); + lifecycle.getCurrentExecutableKey().ifPresent(parent -> { + lifecycle.startStep(parent, chain.getKey(), chain.getStep()); + lifecycle.stopStep(chain.getKey()); + }); return; } diff --git a/allure-assertj/src/test/java/io/qameta/allure/assertj/AllureAspectJTest.java b/allure-assertj/src/test/java/io/qameta/allure/assertj/AllureAspectJTest.java index 377e7a6b1..3291473e0 100644 --- a/allure-assertj/src/test/java/io/qameta/allure/assertj/AllureAspectJTest.java +++ b/allure-assertj/src/test/java/io/qameta/allure/assertj/AllureAspectJTest.java @@ -36,6 +36,8 @@ import static io.qameta.allure.test.RunUtils.runWithinTestContext; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.tuple; +import io.qameta.allure.test.IsolatedLifecycle; +@IsolatedLifecycle class AllureAspectJTest { @AllureFeatures.Steps @@ -44,7 +46,7 @@ void shouldCreateSemanticChainForScalarAssert() { final AllureResults results = runWithinTestContext(() -> { assertThat("Data") .hasSize(4); - }, AllureAspectJ::setLifecycle); + }); final TestResult result = assertOnlyOneResult(results); assertThat(result.getSteps()) @@ -67,7 +69,7 @@ void shouldUseAssertDescriptionAsChainName() { assertThat((Object) null) .as("Nullable object") .isNull(); - }, AllureAspectJ::setLifecycle); + }); final TestResult result = assertOnlyOneResult(results); assertThat(result.getSteps()) @@ -87,7 +89,7 @@ void shouldRenderByteArraysWithoutPayload() { assertThat(value.getBytes(StandardCharsets.UTF_8)) .as("Byte array object") .isEqualTo(value.getBytes(StandardCharsets.UTF_8)); - }, AllureAspectJ::setLifecycle); + }); final TestResult result = assertOnlyOneResult(results); assertThat(result.getSteps()) @@ -105,7 +107,7 @@ void shouldRenderCollectionsAsSubjectsAndExpectedValuesAsValues() { final AllureResults results = runWithinTestContext(() -> { assertThat(Arrays.asList("a", "b")) .containsExactly("a", "b"); - }, AllureAspectJ::setLifecycle); + }); final TestResult result = assertOnlyOneResult(results); assertThat(result.getSteps()) @@ -130,7 +132,7 @@ void shouldRenderSmallArraysAsValues() { assertThat(new String[]{"alpha", "bravo"}) .containsExactly("alpha", "bravo"); - }, AllureAspectJ::setLifecycle); + }); final TestResult result = assertOnlyOneResult(results); assertThat(result.getSteps()) @@ -156,7 +158,7 @@ void shouldRenderTuplesAsValues() { tuple("first", Status.PASSED), tuple("second", Status.FAILED) ); - }, AllureAspectJ::setLifecycle); + }); final TestResult result = assertOnlyOneResult(results); assertThat(result.getSteps()) @@ -178,7 +180,7 @@ void shouldRenderNullValuesInContainsExactlyInAnyOrder() { final AllureResults results = runWithinTestContext(() -> { assertThat(Arrays.asList(null, "a", "b")) .containsExactlyInAnyOrder(null, "a", "b"); - }, AllureAspectJ::setLifecycle); + }); final TestResult result = assertOnlyOneResult(results); assertThat(result.getSteps()) @@ -203,7 +205,7 @@ void shouldRenderNullValuesAfterExtractingAndKeepLambdaVarargs() { assertThat(model) .extracting(TestResult::getDescription, TestResult::getDescriptionHtml) .containsExactly(null, null); - }, AllureAspectJ::setLifecycle); + }); final TestResult result = assertOnlyOneResult(results); assertThat(result.getSteps()) @@ -231,7 +233,7 @@ void shouldRenderFieldOrPropertyValueAssertions() { final AllureResults results = runWithinTestContext(() -> { assertThat(details) .hasFieldOrPropertyWithValue("message", "Make the test failed"); - }, AllureAspectJ::setLifecycle); + }); final TestResult result = assertOnlyOneResult(results); assertThat(result.getSteps()) @@ -255,7 +257,7 @@ void shouldTruncateLongStepNamesAndAddOnlyTruncatedValuesAsParameters() { final AllureResults results = runWithinTestContext(() -> { assertThat(value) .isEqualTo(value); - }, AllureAspectJ::setLifecycle); + }); final TestResult result = assertOnlyOneResult(results); assertThat(result.getSteps()) @@ -294,7 +296,7 @@ void shouldCreateSeparateChainsForMultipleAssertThatCalls() { assertThat(Arrays.asList("a", "b")) .hasSize(2) .contains("a"); - }, AllureAspectJ::setLifecycle); + }); final TestResult result = assertOnlyOneResult(results); assertThat(result.getSteps()) @@ -328,7 +330,7 @@ void shouldAttachOperationsToStoredAssertionInstances() { a.isEqualTo("alpha"); b.isEqualTo("bravo"); - }, AllureAspectJ::setLifecycle); + }); final TestResult result = assertOnlyOneResult(results); assertThat(result.getSteps()) @@ -361,7 +363,7 @@ void shouldAvoidVerboseModelToStringPayloads() { assertThat(Collections.singletonList(model)) .hasSize(1) .containsExactly(model); - }, AllureAspectJ::setLifecycle); + }); final TestResult result = assertOnlyOneResult(results); assertThat(result.getSteps()) @@ -408,7 +410,7 @@ void shouldKeepNavigationInsideTheSameChain() { assertThat(Collections.singletonList(Collections.singletonList("delta"))) .flatExtracting(value -> value) .containsExactly("delta"); - }, AllureAspectJ::setLifecycle); + }); final TestResult result = assertOnlyOneResult(results); assertThat(result.getSteps()) @@ -440,7 +442,7 @@ void shouldRenderSerializedLambdaMethodReferences() { assertThat(Collections.singletonList(model)) .extracting((Function & Serializable) TestResult::getFullName) .containsExactly("my.company.Test.testOne"); - }, AllureAspectJ::setLifecycle); + }); final TestResult result = assertOnlyOneResult(results); assertThat(result.getSteps()) @@ -458,7 +460,7 @@ void shouldMarkTheFailedHardAssertionOperation() { final AllureResults results = runWithinTestContext(() -> { assertThat("Data") .hasSize(5); - }, AllureAspectJ::setLifecycle); + }); final TestResult result = assertOnlyOneResult(results); assertThat(result.getSteps()) @@ -486,7 +488,7 @@ void shouldMarkTheFailedSoftAssertionOperationBeforeAssertAll() { .as("Age") .isEqualTo(26); soft.assertAll(); - }, AllureAspectJ::setLifecycle); + }); final TestResult result = assertOnlyOneResult(results); assertThat(result.getSteps()) @@ -518,7 +520,7 @@ void shouldAttachNestedAssertionsUnderCallbackOperations() { .startsWith("al") .endsWith("ha") ); - }, AllureAspectJ::setLifecycle); + }); final TestResult result = assertOnlyOneResult(results); assertThat(result.getSteps()) diff --git a/allure-awaitility/src/main/java/io/qameta/allure/awaitility/AllureAwaitilityListener.java b/allure-awaitility/src/main/java/io/qameta/allure/awaitility/AllureAwaitilityListener.java index 5cd3b2737..41e7532bc 100644 --- a/allure-awaitility/src/main/java/io/qameta/allure/awaitility/AllureAwaitilityListener.java +++ b/allure-awaitility/src/main/java/io/qameta/allure/awaitility/AllureAwaitilityListener.java @@ -16,7 +16,10 @@ package io.qameta.allure.awaitility; import io.qameta.allure.Allure; +import io.qameta.allure.AllureExternalKey; import io.qameta.allure.AllureLifecycle; +import io.qameta.allure.AllureThreadBinding; +import io.qameta.allure.AttachmentOptions; import io.qameta.allure.model.Status; import io.qameta.allure.model.StepResult; import org.awaitility.Awaitility; @@ -27,10 +30,10 @@ import org.awaitility.core.StartEvaluationEvent; import org.awaitility.core.TimeoutEvent; +import java.io.ByteArrayInputStream; import java.io.PrintWriter; import java.io.StringWriter; import java.nio.charset.StandardCharsets; -import java.util.UUID; import java.util.concurrent.TimeUnit; import static java.util.concurrent.TimeUnit.MILLISECONDS; @@ -74,14 +77,9 @@ public class AllureAwaitilityListener implements ConditionEvaluationListener LIFECYCLE = new InheritableThreadLocal() { - @Override - protected AllureLifecycle initialValue() { - return Allure.getLifecycle(); - } - }; + private AllureThreadBinding currentConditionBinding; /** * Returns the lifecycle. @@ -89,7 +87,7 @@ protected AllureLifecycle initialValue() { * @return the Allure lifecycle used by this integration */ public static AllureLifecycle getLifecycle() { - return LIFECYCLE.get(); + return Allure.getLifecycle(); } /** @@ -134,17 +132,25 @@ public AllureAwaitilityListener setLogIgnoredExceptions(final boolean logging) { */ @Override public void beforeEvaluation(final StartEvaluationEvent startEvaluationEvent) { - currentConditionStepUUID = UUID.randomUUID().toString(); - final String nameWoAlias = String.format(onStartStepTextPattern, startEvaluationEvent.getDescription()); - final String nameWithAlias = String.format(onStartStepTextPattern, startEvaluationEvent.getAlias()); - final String stepName = startEvaluationEvent.getAlias() != null ? nameWithAlias : nameWoAlias; - getLifecycle().startStep( - currentConditionStepUUID, - new StepResult() - .setName(stepName) - .setDescription("Awaitility condition started") - .setStatus(Status.FAILED) - ); + currentConditionStepKey = null; + getLifecycle().getCurrentExecutableKey().ifPresent(parent -> { + final String nameWoAlias = String.format(onStartStepTextPattern, startEvaluationEvent.getDescription()); + final String nameWithAlias = String.format(onStartStepTextPattern, startEvaluationEvent.getAlias()); + final String stepName = startEvaluationEvent.getAlias() != null ? nameWithAlias : nameWoAlias; + final AllureExternalKey conditionStepKey = AllureExternalKey.random(AllureAwaitilityListener.class); + currentConditionStepKey = conditionStepKey; + getLifecycle().startStep( + parent, + conditionStepKey, + new StepResult() + .setName(stepName) + .setDescription("Awaitility condition started") + .setStatus(Status.FAILED) + ); + // bind the polling thread to the condition step, so steps produced while evaluating the + // condition — for example assertion steps from untilAsserted polls — nest under it + currentConditionBinding = getLifecycle().bindDetached(conditionStepKey); + }); } /** @@ -154,19 +160,18 @@ public void beforeEvaluation(final StartEvaluationEvent startEvaluationE */ @Override public void onTimeout(final TimeoutEvent timeoutEvent) { - getLifecycle().updateStep(awaitilityCondition -> { - final String currentTimeoutStepUUID = UUID.randomUUID().toString(); - getLifecycle().startStep( - currentConditionStepUUID, - currentTimeoutStepUUID, - new StepResult() - .setName(String.format(onTimeoutStepTextPattern, timeoutEvent.getDescription())) - .setDescription("Awaitility condition timeout") - .setStatus(Status.BROKEN) - ); - getLifecycle().stopStep(currentTimeoutStepUUID); - }); - getLifecycle().stopStep(currentConditionStepUUID); + if (currentConditionStepKey == null) { + return; + } + closeConditionBinding(); + getLifecycle().logStep( + currentConditionStepKey, + new StepResult() + .setName(String.format(onTimeoutStepTextPattern, timeoutEvent.getDescription())) + .setDescription("Awaitility condition timeout") + .setStatus(Status.BROKEN) + ); + getLifecycle().stopStep(currentConditionStepKey); } /** @@ -191,22 +196,30 @@ public void conditionEvaluated(final EvaluatedCondition condition) { new TemporalDuration(condition.getPollInterval()) ); - getLifecycle().updateStep(awaitilityCondition -> { - final String lastAwaitStepUUID = UUID.randomUUID().toString(); - getLifecycle().startStep( - currentConditionStepUUID, - lastAwaitStepUUID, - new StepResult() - .setName(message) - .setDescription("Awaitility condition satisfied or not, but awaiting still in progress") - .setStatus(Status.PASSED) + if (currentConditionStepKey == null) { + return; + } + getLifecycle().logStep( + currentConditionStepKey, + new StepResult() + .setName(message) + .setDescription("Awaitility condition satisfied or not, but awaiting still in progress") + .setStatus(Status.PASSED) + ); + if (condition.isSatisfied()) { + closeConditionBinding(); + getLifecycle().updateStep( + currentConditionStepKey, awaitilityCondition -> awaitilityCondition.setStatus(Status.PASSED) ); - getLifecycle().stopStep(lastAwaitStepUUID); - if (condition.isSatisfied()) { - awaitilityCondition.setStatus(Status.PASSED); - getLifecycle().stopStep(currentConditionStepUUID); - } - }); + getLifecycle().stopStep(currentConditionStepKey); + } + } + + private void closeConditionBinding() { + if (currentConditionBinding != null) { + currentConditionBinding.close(); + currentConditionBinding = null; + } } /** @@ -224,39 +237,31 @@ public void conditionEvaluated(final EvaluatedCondition condition) { */ @Override public void exceptionIgnored(final IgnoredException ignoredException) { - if (logIgnoredExceptions) { - getLifecycle().updateStep(awaitilityCondition -> { - final String currentExceptionIgnoredStepUUID = UUID.randomUUID().toString(); - final String message = String.format( - onExceptionStepTextPattern, ignoredException.getThrowable().getMessage() - ); - final StringWriter stringWriter = new StringWriter(); - ignoredException.getThrowable().printStackTrace(new PrintWriter(stringWriter)); - final String stackTrace = stringWriter.toString(); - getLifecycle().startStep( - currentConditionStepUUID, - currentExceptionIgnoredStepUUID, - new StepResult() - .setName(message) - .setDescription("Exception occurred and ignored, but awaiting still in progress") - .setStatus(Status.SKIPPED) - ); - getLifecycle().addAttachment( - ignoredException.getThrowable().getMessage(), "text/plain", ".txt", - stackTrace.getBytes(StandardCharsets.UTF_8) - ); - getLifecycle().stopStep(currentExceptionIgnoredStepUUID); - }); + if (logIgnoredExceptions && currentConditionStepKey != null) { + final AllureExternalKey exceptionIgnoredStepKey = AllureExternalKey.random(AllureAwaitilityListener.class); + final String message = String.format( + onExceptionStepTextPattern, ignoredException.getThrowable().getMessage() + ); + final StringWriter stringWriter = new StringWriter(); + ignoredException.getThrowable().printStackTrace(new PrintWriter(stringWriter)); + final String stackTrace = stringWriter.toString(); + getLifecycle().startStep( + currentConditionStepKey, + exceptionIgnoredStepKey, + new StepResult() + .setName(message) + .setDescription("Exception occurred and ignored, but awaiting still in progress") + .setStatus(Status.SKIPPED) + ); + getLifecycle().addAttachment( + exceptionIgnoredStepKey, + ignoredException.getThrowable().getMessage(), + "text/plain", + new ByteArrayInputStream(stackTrace.getBytes(StandardCharsets.UTF_8)), + AttachmentOptions.empty() + ); + getLifecycle().stopStep(exceptionIgnoredStepKey); } } - /** - * For tests only. - * - * @param allure allure lifecycle to set - */ - public static void setLifecycle(final AllureLifecycle allure) { - LIFECYCLE.set(allure); - } - } diff --git a/allure-awaitility/src/test/java/io/qameta/allure/awaitility/ConditionListenersPositiveTest.java b/allure-awaitility/src/test/java/io/qameta/allure/awaitility/ConditionListenersPositiveTest.java index 4f25360d0..3515dc0fb 100644 --- a/allure-awaitility/src/test/java/io/qameta/allure/awaitility/ConditionListenersPositiveTest.java +++ b/allure-awaitility/src/test/java/io/qameta/allure/awaitility/ConditionListenersPositiveTest.java @@ -31,7 +31,9 @@ import static io.qameta.allure.test.RunUtils.runWithinTestContext; import static org.assertj.core.api.Assertions.assertThat; import static org.awaitility.Awaitility.await; +import io.qameta.allure.test.IsolatedLifecycle; +@IsolatedLifecycle class ConditionListenersPositiveTest { private static final String AWAITILITY_EVALUATION_DESCRIPTION = "Awaitility condition satisfied or not, but awaiting still in progress"; @@ -201,9 +203,7 @@ private List runAwaitWithoutAliasTopLevelSteps() { .atMost(Duration.of(1000, ChronoUnit.MILLIS)) .pollInterval(Duration.of(50, ChronoUnit.MILLIS)) .untilAsserted(() -> assertThat(atomicInteger.getAndIncrement()).isEqualTo(3)); - }, - AllureAwaitilityListener::setLifecycle - ).getTestResults(); + } ).getTestResults(); return testResult.get(0).getSteps(); } @@ -220,9 +220,7 @@ private StepResult awaitWithAliasTopLevelStep() { .atMost(Duration.of(1000, ChronoUnit.MILLIS)) .pollInterval(Duration.of(50, ChronoUnit.MILLIS)) .untilAsserted(() -> assertThat(atomicInteger.getAndIncrement()).isEqualTo(3)); - }, - AllureAwaitilityListener::setLifecycle - ).getTestResults(); + } ).getTestResults(); return testResult.get(0).getSteps().get(0); } diff --git a/allure-awaitility/src/test/java/io/qameta/allure/awaitility/GlobalSettingsNegativeTest.java b/allure-awaitility/src/test/java/io/qameta/allure/awaitility/GlobalSettingsNegativeTest.java index c5777be62..703e9d828 100644 --- a/allure-awaitility/src/test/java/io/qameta/allure/awaitility/GlobalSettingsNegativeTest.java +++ b/allure-awaitility/src/test/java/io/qameta/allure/awaitility/GlobalSettingsNegativeTest.java @@ -33,8 +33,10 @@ import static io.qameta.allure.test.RunUtils.runWithinTestContext; import static org.assertj.core.api.Assertions.assertThat; import static org.awaitility.Awaitility.await; +import io.qameta.allure.test.IsolatedLifecycle; @TestInstance(TestInstance.Lifecycle.PER_METHOD) +@IsolatedLifecycle class GlobalSettingsNegativeTest { private static final String AWAITILITY_EVALUATION_DESCRIPTION = "Awaitility condition satisfied or not, but awaiting still in progress"; @@ -143,8 +145,7 @@ private List runTimedOutAwaitWithoutAliasTopLevelSteps() { .atMost(Duration.of(1000, ChronoUnit.MILLIS)) .pollInterval(Duration.of(500, ChronoUnit.MILLIS)) .untilAsserted(() -> assertThat(atomicInteger.getAndIncrement()).isEqualTo(3)); - }, - AllureAwaitilityListener::setLifecycle + } ).getTestResults(); return testResult.get(0).getSteps(); diff --git a/allure-awaitility/src/test/java/io/qameta/allure/awaitility/GlobalSettingsPositiveTest.java b/allure-awaitility/src/test/java/io/qameta/allure/awaitility/GlobalSettingsPositiveTest.java index 2c925e1a3..663c1cc84 100644 --- a/allure-awaitility/src/test/java/io/qameta/allure/awaitility/GlobalSettingsPositiveTest.java +++ b/allure-awaitility/src/test/java/io/qameta/allure/awaitility/GlobalSettingsPositiveTest.java @@ -33,8 +33,10 @@ import static io.qameta.allure.test.RunUtils.runWithinTestContext; import static org.assertj.core.api.Assertions.assertThat; import static org.awaitility.Awaitility.await; +import io.qameta.allure.test.IsolatedLifecycle; @TestInstance(TestInstance.Lifecycle.PER_METHOD) +@IsolatedLifecycle class GlobalSettingsPositiveTest { private static final String AWAITILITY_EVALUATION_DESCRIPTION = "Awaitility condition satisfied or not, but awaiting still in progress"; @@ -212,9 +214,7 @@ private List runAwaitWithoutAliasTopLevelSteps() { .atMost(Duration.of(1000, ChronoUnit.MILLIS)) .pollInterval(Duration.of(50, ChronoUnit.MILLIS)) .untilAsserted(() -> assertThat(atomicInteger.getAndIncrement()).isEqualTo(3)); - }, - AllureAwaitilityListener::setLifecycle - ).getTestResults(); + } ).getTestResults(); return testResult.get(0).getSteps(); } @@ -230,9 +230,7 @@ private StepResult awaitWithAliasTopLevelStep() { .atMost(Duration.of(1000, ChronoUnit.MILLIS)) .pollInterval(Duration.of(50, ChronoUnit.MILLIS)) .untilAsserted(() -> assertThat(atomicInteger.getAndIncrement()).isEqualTo(3)); - }, - AllureAwaitilityListener::setLifecycle - ).getTestResults(); + } ).getTestResults(); return testResult.get(0).getSteps().get(0); } diff --git a/allure-awaitility/src/test/java/io/qameta/allure/awaitility/MultipleConditionsTest.java b/allure-awaitility/src/test/java/io/qameta/allure/awaitility/MultipleConditionsTest.java index ac842bae4..937b038e3 100644 --- a/allure-awaitility/src/test/java/io/qameta/allure/awaitility/MultipleConditionsTest.java +++ b/allure-awaitility/src/test/java/io/qameta/allure/awaitility/MultipleConditionsTest.java @@ -29,7 +29,9 @@ import static io.qameta.allure.test.RunUtils.runWithinTestContext; import static org.assertj.core.api.Assertions.assertThat; import static org.awaitility.Awaitility.await; +import io.qameta.allure.test.IsolatedLifecycle; +@IsolatedLifecycle public class MultipleConditionsTest { @AfterEach @@ -77,8 +79,7 @@ private List runMultipleAwaitilityTopLevelSteps() { await().with() .alias("Second waiting") .until(() -> true); - }, - AllureAwaitilityListener::setLifecycle + } ).getTestResults(); return testResult.get(0).getSteps(); diff --git a/allure-citrus/src/main/java/io/qameta/allure/citrus/AllureCitrus.java b/allure-citrus/src/main/java/io/qameta/allure/citrus/AllureCitrus.java index cc5e669d6..461b18e31 100644 --- a/allure-citrus/src/main/java/io/qameta/allure/citrus/AllureCitrus.java +++ b/allure-citrus/src/main/java/io/qameta/allure/citrus/AllureCitrus.java @@ -21,6 +21,7 @@ import com.consol.citrus.report.TestListener; import com.consol.citrus.report.TestSuiteListener; import io.qameta.allure.Allure; +import io.qameta.allure.AllureExternalKey; import io.qameta.allure.AllureLifecycle; import io.qameta.allure.Description; import io.qameta.allure.Epic; @@ -29,7 +30,6 @@ import io.qameta.allure.model.Label; import io.qameta.allure.model.Link; import io.qameta.allure.model.Parameter; -import io.qameta.allure.model.Stage; import io.qameta.allure.model.Status; import io.qameta.allure.model.StatusDetails; import io.qameta.allure.model.StepResult; @@ -46,7 +46,6 @@ import java.util.Map; import java.util.Objects; import java.util.Optional; -import java.util.UUID; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.locks.ReadWriteLock; import java.util.concurrent.locks.ReentrantReadWriteLock; @@ -69,7 +68,7 @@ */ public class AllureCitrus implements TestListener, TestSuiteListener, TestActionListener { - private final Map testUuids = new ConcurrentHashMap<>(); + private final Map testKeys = new ConcurrentHashMap<>(); private final ReadWriteLock lock = new ReentrantReadWriteLock(); @@ -154,7 +153,7 @@ public void onFinishFailure(final Throwable cause) { */ @Override public void onTestStart(final TestCase test) { - startTestCase(test); + startTest(test); } /** @@ -170,7 +169,7 @@ public void onTestFinish(final TestCase test) { */ @Override public void onTestSuccess(final TestCase test) { - stopTestCase(test, Status.PASSED, null); + stopTest(test, Status.PASSED, null); } /** @@ -180,7 +179,7 @@ public void onTestSuccess(final TestCase test) { public void onTestFailure(final TestCase test, final Throwable cause) { final Status status = ResultsUtils.getStatus(cause).orElse(Status.BROKEN); final StatusDetails details = ResultsUtils.getStatusDetails(cause).orElse(null); - stopTestCase(test, status, details); + stopTest(test, status, details); } /** @@ -196,9 +195,7 @@ public void onTestSkipped(final TestCase test) { */ @Override public void onTestActionStart(final TestCase testCase, final TestAction testAction) { - final String parentUuid = getUuid(testCase); - final String uuid = UUID.randomUUID().toString(); - getLifecycle().startStep(parentUuid, uuid, new StepResult().setName(testAction.getName())); + getLifecycle().startStep(new StepResult().setName(testAction.getName())); } /** @@ -217,19 +214,17 @@ public void onTestActionSkipped(final TestCase testCase, final TestAction testAc //do nothing } - private void startTestCase(final TestCase testCase) { - final String uuid = createUuid(testCase); + private void startTest(final TestCase testCase) { + final AllureExternalKey testKey = createTestKey(testCase); final Optional> testClass = Optional.ofNullable(testCase.getTestClass()); final TestResult result = new TestResult() - .setUuid(uuid) .setName(testCase.getName()) .setTitlePath( testClass .map(ResultsUtils::createTitlePathFromJavaClass) .orElseGet(() -> createTitlePath(testCase.getName())) - ) - .setStage(Stage.RUNNING); + ); result.getLabels().addAll(getProvidedLabels()); @@ -258,53 +253,43 @@ private void startTestCase(final TestCase testCase) { result.setDescription(description); - getLifecycle().scheduleTestCase(result); - getLifecycle().startTestCase(uuid); + getLifecycle().scheduleTest(testKey, result); + getLifecycle().startTest(testKey); } - private void stopTestCase(final TestCase testCase, + private void stopTest(final TestCase testCase, final Status status, final StatusDetails details) { - final String uuid = removeUuid(testCase); + final AllureExternalKey testKey = removeTestKey(testCase); final Map definitions = testCase.getVariableDefinitions(); final List parameters = definitions.entrySet().stream() .map(entry -> createParameter(entry.getKey(), entry.getValue())) .collect(Collectors.toList()); - getLifecycle().updateTestCase(uuid, result -> { + getLifecycle().updateTest(testKey, result -> { result.setParameters(parameters); - result.setStage(Stage.FINISHED); result.setStatus(status); result.setStatusDetails(details); }); - getLifecycle().stopTestCase(uuid); - getLifecycle().writeTestCase(uuid); + getLifecycle().stopTest(testKey); + getLifecycle().writeTest(testKey); } - private String createUuid(final TestCase testCase) { - final String uuid = UUID.randomUUID().toString(); + private AllureExternalKey createTestKey(final TestCase testCase) { + final AllureExternalKey testKey = AllureExternalKey.random(AllureCitrus.class); try { lock.writeLock().lock(); - testUuids.put(testCase, uuid); + testKeys.put(testCase, testKey); } finally { lock.writeLock().unlock(); } - return uuid; - } - - private String getUuid(final TestCase testCase) { - try { - lock.readLock().lock(); - return testUuids.get(testCase); - } finally { - lock.readLock().unlock(); - } + return testKey; } - private String removeUuid(final TestCase testCase) { + private AllureExternalKey removeTestKey(final TestCase testCase) { try { lock.writeLock().lock(); - return testUuids.remove(testCase); + return testKeys.remove(testCase); } finally { lock.writeLock().unlock(); } diff --git a/allure-citrus/src/test/java/io/qameta/allure/citrus/AllureCitrusTest.java b/allure-citrus/src/test/java/io/qameta/allure/citrus/AllureCitrusTest.java index e0e73c892..a43f2433f 100644 --- a/allure-citrus/src/test/java/io/qameta/allure/citrus/AllureCitrusTest.java +++ b/allure-citrus/src/test/java/io/qameta/allure/citrus/AllureCitrusTest.java @@ -34,13 +34,16 @@ import io.qameta.allure.test.AllureFeatures; import io.qameta.allure.test.AllureResults; import io.qameta.allure.test.AllureResultsWriterStub; +import io.qameta.allure.test.RunUtils; import org.junit.jupiter.api.Test; import java.time.Instant; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.tuple; +import io.qameta.allure.test.IsolatedLifecycle; @SuppressWarnings("unchecked") +@IsolatedLifecycle class AllureCitrusTest { @AllureFeatures.Base @@ -202,45 +205,50 @@ void shouldSetParameters() { @Step("Run test case {testDesigner}") private AllureResults run(final TestDesigner testDesigner) { - final CitrusContext citrusContext = CitrusContext.create(); - final AllureResultsWriterStub resultsWriterStub = new AllureResultsWriterStub(); - final AllureLifecycle defaultLifecycle = Allure.getLifecycle(); - final AllureLifecycle lifecycle = new AllureLifecycle(resultsWriterStub); - final AllureCitrus allureCitrus = new AllureCitrus(lifecycle); - final Citrus citrus = Citrus.newInstance(() -> citrusContext); - final TestContext testContext = citrusContext.createTestContext(); - testContext.getTestListeners().addTestListener(allureCitrus); - testContext.getTestActionListeners().addTestActionListener(allureCitrus); - try { - Allure.setLifecycle(lifecycle); - testDesigner.setTestContext(testContext); - final TestCase testCase = testDesigner.getTestCase(); - - Throwable failure = null; - try { - citrus.run(testCase, testContext); - } catch (Exception | AssertionError e) { - failure = e; - } - try { - testCase.finish(testContext); - } catch (Exception | AssertionError e) { - if (failure == null) { - failure = e; - } - } - if (failure != null && resultsWriterStub.getTestResults().isEmpty()) { - throw new IllegalStateException("Citrus test execution failed before Allure received test events", failure); - } - } catch (Exception e) { - if (resultsWriterStub.getTestResults().isEmpty()) { - throw new IllegalStateException("Citrus test execution failed before Allure received test events", e); - } - } finally { - Allure.setLifecycle(defaultLifecycle); - citrus.close(); - } - - return resultsWriterStub; + // a failing citrus test is a valid outcome under test — only fail the harness when + // the failure prevented Allure from receiving any test events at all + final AllureResultsWriterStub[] writerRef = new AllureResultsWriterStub[1]; + return RunUtils.runTests( + writer -> { + writerRef[0] = (AllureResultsWriterStub) writer; + return new AllureLifecycle(writer); + }, + lifecycle -> { + final CitrusContext citrusContext = CitrusContext.create(); + final AllureCitrus allureCitrus = new AllureCitrus(lifecycle); + final Citrus citrus = Citrus.newInstance(() -> citrusContext); + final TestContext testContext = citrusContext.createTestContext(); + testContext.getTestListeners().addTestListener(allureCitrus); + testContext.getTestActionListeners().addTestActionListener(allureCitrus); + try { + testDesigner.setTestContext(testContext); + final TestCase testCase = testDesigner.getTestCase(); + + Throwable failure = null; + try { + citrus.run(testCase, testContext); + } catch (Exception | AssertionError e) { + failure = e; + } + try { + testCase.finish(testContext); + } catch (Exception | AssertionError e) { + if (failure == null) { + failure = e; + } + } + if (failure != null && writerRef[0].getTestResults().isEmpty()) { + throw new IllegalStateException( + "Citrus test execution failed before Allure received test events", failure); + } + } catch (Exception e) { + if (writerRef[0].getTestResults().isEmpty()) { + throw new IllegalStateException( + "Citrus test execution failed before Allure received test events", e); + } + } finally { + citrus.close(); + } + }); } } diff --git a/allure-cucumber7-jvm/src/main/java/io/qameta/allure/cucumber7jvm/AllureCucumber7Jvm.java b/allure-cucumber7-jvm/src/main/java/io/qameta/allure/cucumber7jvm/AllureCucumber7Jvm.java index 452fcc97a..6b7e98795 100644 --- a/allure-cucumber7-jvm/src/main/java/io/qameta/allure/cucumber7jvm/AllureCucumber7Jvm.java +++ b/allure-cucumber7-jvm/src/main/java/io/qameta/allure/cucumber7jvm/AllureCucumber7Jvm.java @@ -39,11 +39,12 @@ import io.cucumber.plugin.event.TestStepStarted; import io.cucumber.plugin.event.WriteEvent; import io.qameta.allure.Allure; +import io.qameta.allure.AllureExternalKey; import io.qameta.allure.AllureLifecycle; +import io.qameta.allure.AttachmentOptions; import io.qameta.allure.cucumber7jvm.testsourcemodel.TestSourcesModelProxy; import io.qameta.allure.model.FixtureResult; import io.qameta.allure.model.Parameter; -import io.qameta.allure.model.ScopeResult; import io.qameta.allure.model.Status; import io.qameta.allure.model.StatusDetails; import io.qameta.allure.model.StepResult; @@ -81,12 +82,18 @@ { "ClassDataAbstractionCoupling", "ClassFanOutComplexity", + "PMD.TooManyMethods", + "PMD.GodClass", } ) public class AllureCucumber7Jvm implements ConcurrentEventListener { private static final String COLON = ":"; + private static final String CSV_DELIMITER = ","; + private static final String CSV_QUOTE = "\""; + private static final String CSV_ESCAPED_QUOTE = CSV_QUOTE + CSV_QUOTE; private static final String NEW_LINE = "\n"; + private static final String CARRIAGE_RETURN = "\r"; private final AllureLifecycle lifecycle; @@ -102,7 +109,6 @@ public class AllureCucumber7Jvm implements ConcurrentEventListener { private final Map hookStepContainerUuid = new ConcurrentHashMap<>(); - private static final String TXT_EXTENSION = ".txt"; private static final String TEXT_PLAIN = "text/plain"; private static final String CUCUMBER_WORKING_DIR = Paths.get("").toUri().getSchemeSpecificPart(); @@ -140,6 +146,22 @@ public void setEventPublisher(final EventPublisher publisher) { publisher.registerHandlerFor(EmbedEvent.class, embedEventHandler); } + private static AllureExternalKey scopeKey(final String uuid) { + return AllureExternalKey.of(AllureCucumber7Jvm.class, "scope", uuid); + } + + private static AllureExternalKey testKey(final String uuid) { + return AllureExternalKey.of(AllureCucumber7Jvm.class, "test", uuid); + } + + private static AllureExternalKey fixtureKey(final String uuid) { + return AllureExternalKey.of(AllureCucumber7Jvm.class, "fixture", uuid); + } + + private static AllureExternalKey stepKey(final String uuid) { + return AllureExternalKey.of(AllureCucumber7Jvm.class, "step", uuid); + } + private void handleFeatureStartedHandler(final TestSourceRead event) { testSources.addTestSourceReadEvent(event.getUri(), event); } @@ -197,8 +219,9 @@ private void handleTestCaseStarted(final TestCaseStarted event) { result.setDescription(description); } - lifecycle.scheduleTestCase(result); - lifecycle.startTestCase(testCaseUuid); + final AllureExternalKey testKey = testKey(testCaseUuid); + lifecycle.scheduleTest(testKey, result); + lifecycle.startTest(testKey); } private void handleTestCaseFinished(final TestCaseFinished event) { @@ -216,14 +239,15 @@ private void handleTestCaseFinished(final TestCaseFinished event) { .setMuted(tagParser.isMuted()) .setKnown(tagParser.isKnown()); - lifecycle.updateTestCase( - uuid, testResult -> testResult + final AllureExternalKey testKey = testKey(uuid); + lifecycle.updateTest( + testKey, testResult -> testResult .setStatus(status) .setStatusDetails(statusDetails) ); - lifecycle.stopTestCase(uuid); - lifecycle.writeTestCase(uuid); + lifecycle.stopTest(testKey); + lifecycle.writeTest(testKey); } private void handleTestStepStarted(final TestStepStarted event) { @@ -250,13 +274,14 @@ private void handleStartPickleStep(final TestCase testCase, .setName(step.getKeyword() + step.getText()) .setStart(System.currentTimeMillis()); - lifecycle.setCurrentTestCase(uuid); - lifecycle.startStep(uuid, pickleStep.getId().toString(), stepResult); + final AllureExternalKey stepKey = stepKey(pickleStep.getId().toString()); + lifecycle.setCurrent(testKey(uuid)); + lifecycle.startStep(stepKey, stepResult); final StepArgument stepArgument = step.getArgument(); if (stepArgument instanceof DataTableArgument) { final DataTableArgument dataTableArgument = (DataTableArgument) stepArgument; - createDataTableAttachment(dataTableArgument); + createDataTableAttachment(stepKey, dataTableArgument); } } @@ -267,8 +292,8 @@ private void handleStartStepHook(final TestCase testCase, .setName(hook.getCodeLocation()) .setStart(System.currentTimeMillis()); - lifecycle.setCurrentTestCase(uuid); - lifecycle.startStep(uuid, hook.getId().toString(), stepResult); + lifecycle.setCurrent(testKey(uuid)); + lifecycle.startStep(stepKey(hook.getId().toString()), stepResult); } private void handleStartFixtureHook(final TestCase testCase, @@ -279,20 +304,19 @@ private void handleStartFixtureHook(final TestCase testCase, final String containerUuid = hookStepContainerUuid .computeIfAbsent(hookId, unused -> UUID.randomUUID().toString()); - lifecycle.startScope( - new ScopeResult() - .setUuid(containerUuid) - .setTests(Collections.singletonList(uuid)) - ); + final AllureExternalKey scopeKey = scopeKey(containerUuid); + lifecycle.registerScope(scopeKey); + lifecycle.addTestToScope(scopeKey, testKey(uuid)); final FixtureResult hookResult = new FixtureResult() .setName(hook.getCodeLocation()); final String fixtureUuid = hookId.toString(); + final AllureExternalKey fixtureKey = fixtureKey(fixtureUuid); if (hook.getHookType() == HookType.BEFORE) { - lifecycle.startBeforeFixture(containerUuid, fixtureUuid, hookResult); + lifecycle.startBeforeFixture(scopeKey, fixtureKey, hookResult); } else { - lifecycle.startAfterFixture(containerUuid, fixtureUuid, hookResult); + lifecycle.startAfterFixture(scopeKey, fixtureKey, hookResult); } } @@ -315,16 +339,29 @@ private static boolean isFixtureHook(final HookTestStep hook) { } private void handleWriteEvent(final WriteEvent event) { - lifecycle.addAttachment( - "Text output", - TEXT_PLAIN, - TXT_EXTENSION, - Objects.toString(event.getText()).getBytes(StandardCharsets.UTF_8) + // user output is genuinely ambient: it lands under whatever executable is current, + // and is silently skipped (unsupported executables) — no key to address it by + lifecycle.getCurrentExecutableKey().ifPresent( + key -> lifecycle.addAttachment( + key, + "Text output", + TEXT_PLAIN, + new ByteArrayInputStream(Objects.toString(event.getText()).getBytes(StandardCharsets.UTF_8)), + AttachmentOptions.empty() + ) ); } private void handleEmbedEvent(final EmbedEvent event) { - lifecycle.addAttachment(event.name, event.getMediaType(), null, new ByteArrayInputStream(event.getData())); + lifecycle.getCurrentExecutableKey().ifPresent( + key -> lifecycle.addAttachment( + key, + event.name, + event.getMediaType(), + new ByteArrayInputStream(event.getData()), + AttachmentOptions.empty() + ) + ); } private String getHistoryId(final TestCase testCase) { @@ -410,21 +447,39 @@ private List getExamplesAsParameters( .collect(Collectors.toList()); } - private void createDataTableAttachment(final DataTableArgument dataTableArgument) { + private void createDataTableAttachment(final AllureExternalKey stepKey, + final DataTableArgument dataTableArgument) { + // the data table belongs to the step this adapter just started — address it by key + lifecycle.addAttachment( + stepKey, + "Data table", + "text/csv", + new ByteArrayInputStream(toCsv(dataTableArgument).getBytes(StandardCharsets.UTF_8)), + AttachmentOptions.empty() + ); + } + + private static String toCsv(final DataTableArgument dataTableArgument) { final List> rowsInTable = dataTableArgument.cells(); final StringBuilder dataTableCsv = new StringBuilder(); for (List columns : rowsInTable) { if (!columns.isEmpty()) { - final String rowValue = columns.stream().collect(Collectors.joining("\t", "", NEW_LINE)); + final String rowValue = columns.stream() + .map(value -> { + final String text = Objects.toString(value, ""); + if (text.contains(CSV_DELIMITER) + || text.contains(CSV_QUOTE) + || text.contains(NEW_LINE) + || text.contains(CARRIAGE_RETURN)) { + return CSV_QUOTE + text.replace(CSV_QUOTE, CSV_ESCAPED_QUOTE) + CSV_QUOTE; + } + return text; + }) + .collect(Collectors.joining(CSV_DELIMITER, "", NEW_LINE)); dataTableCsv.append(rowValue); } } - final String attachmentSource = lifecycle - .prepareAttachment("Data table", "text/tab-separated-values", "csv"); - lifecycle.writeAttachment( - attachmentSource, - new ByteArrayInputStream(dataTableCsv.toString().getBytes(StandardCharsets.UTF_8)) - ); + return dataTableCsv.toString(); } private void handleStopHookStep(final Result eventResult, @@ -441,15 +496,15 @@ private void handleStopHookStep(final Result eventResult, final StatusDetails statusDetails = getStatusDetails(eventResult.getError()) .orElseGet(StatusDetails::new); + final AllureExternalKey fixtureKey = fixtureKey(uuid); lifecycle.updateFixture( - uuid, result -> result + fixtureKey, result -> result .setStatus(status) .setStatusDetails(statusDetails) ); - lifecycle.stopFixture(uuid); + lifecycle.stopFixture(fixtureKey); - lifecycle.stopScope(containerUuid); - lifecycle.writeScope(containerUuid); + lifecycle.writeScope(scopeKey(containerUuid)); } private void handleStopStep(final TestCase testCase, @@ -470,13 +525,13 @@ private void handleStopStep(final TestCase testCase, .setMuted(tagParser.isMuted()) .setKnown(tagParser.isKnown()); - final String stepUuid = stepId.toString(); + final AllureExternalKey stepKey = stepKey(stepId.toString()); lifecycle.updateStep( - stepUuid, + stepKey, stepResult -> stepResult .setStatus(stepStatus) .setStatusDetails(statusDetails) ); - lifecycle.stopStep(stepUuid); + lifecycle.stopStep(stepKey); } } diff --git a/allure-cucumber7-jvm/src/main/java/io/qameta/allure/cucumber7jvm/testsourcemodel/TestSourcesModel.java b/allure-cucumber7-jvm/src/main/java/io/qameta/allure/cucumber7jvm/testsourcemodel/TestSourcesModel.java index ea654c3a8..ce85df218 100644 --- a/allure-cucumber7-jvm/src/main/java/io/qameta/allure/cucumber7jvm/testsourcemodel/TestSourcesModel.java +++ b/allure-cucumber7-jvm/src/main/java/io/qameta/allure/cucumber7jvm/testsourcemodel/TestSourcesModel.java @@ -165,6 +165,9 @@ private AstNode createAstNode(final Object node, final AstNode astNode) { return new AstNode(node, astNode); } + /** + * A node of the parsed Gherkin document tree, linked to its parent for upward traversal. + */ private static class AstNode { private final Object node; private final AstNode parent; diff --git a/allure-cucumber7-jvm/src/test/java/io/qameta/allure/cucumber7jvm/AllureCucumber7JvmTest.java b/allure-cucumber7-jvm/src/test/java/io/qameta/allure/cucumber7jvm/AllureCucumber7JvmTest.java index 3a43a50cd..92e520c9c 100644 --- a/allure-cucumber7-jvm/src/test/java/io/qameta/allure/cucumber7jvm/AllureCucumber7JvmTest.java +++ b/allure-cucumber7-jvm/src/test/java/io/qameta/allure/cucumber7jvm/AllureCucumber7JvmTest.java @@ -61,6 +61,8 @@ import static org.assertj.core.api.Assertions.tuple; import static org.junit.jupiter.api.parallel.ResourceAccessMode.READ_WRITE; import static org.junit.jupiter.api.parallel.Resources.SYSTEM_PROPERTIES; +import io.qameta.allure.test.IsolatedLifecycle; +@IsolatedLifecycle class AllureCucumber7JvmTest { @AllureFeatures.Base @@ -215,20 +217,21 @@ void shouldAddDataTableAttachment() { assertThat(attachments) .extracting(Attachment::getName, Attachment::getType) .containsExactlyInAnyOrder( - tuple("Data table", "text/tab-separated-values") + tuple("Data table", "text/csv") ); final Attachment dataTableAttachment = attachments.iterator().next(); + assertThat(dataTableAttachment.getSource()) + .endsWith(".csv"); assertThat(results.getAttachments()) .containsKeys(dataTableAttachment.getSource()); assertThat(results.getAttachmentContentAsString(dataTableAttachment)) .isEqualTo( - """ - name\tlogin\temail - Viktor\tclicman\tclicman@ya.ru - Viktor2\tclicman2\tclicman2@ya.ru - """ + "name,login,email\n" + + "Viktor,clicman,clicman@ya.ru\n" + + "Viktor2,clicman2,clicman2@ya.ru\n" + + "\"Comma, User\",\"quote \"\"login\"\"\",comma@example.org\n" ); } diff --git a/allure-cucumber7-jvm/src/test/java/io/qameta/allure/cucumber7jvm/samples/RuntimeApiSteps.java b/allure-cucumber7-jvm/src/test/java/io/qameta/allure/cucumber7jvm/samples/RuntimeApiSteps.java index 393f880fd..a0535b302 100644 --- a/allure-cucumber7-jvm/src/test/java/io/qameta/allure/cucumber7jvm/samples/RuntimeApiSteps.java +++ b/allure-cucumber7-jvm/src/test/java/io/qameta/allure/cucumber7jvm/samples/RuntimeApiSteps.java @@ -36,8 +36,8 @@ public void beforeFeature() { public void step1() { Allure.step("step1 nested"); Allure.link("step1", "https://example.org/step1"); - Allure.getLifecycle().getCurrentTestCase().ifPresent(uuid -> { - System.out.println("step1: " + uuid); + Allure.getLifecycle().getCurrentRootKey().ifPresent(key -> { + System.out.println("step1: " + key); }); } @@ -45,8 +45,8 @@ public void step1() { public void step2() { Allure.step("step2 nested"); Allure.link("step2", "https://example.org/step2"); - Allure.getLifecycle().getCurrentTestCase().ifPresent(uuid -> { - System.out.println("step2: " + uuid); + Allure.getLifecycle().getCurrentRootKey().ifPresent(key -> { + System.out.println("step2: " + key); }); } @@ -54,8 +54,8 @@ public void step2() { public void step3() { Allure.step("step3 nested"); Allure.link("step3", "https://example.org/step3"); - Allure.getLifecycle().getCurrentTestCase().ifPresent(uuid -> { - System.out.println("step3: " + uuid); + Allure.getLifecycle().getCurrentRootKey().ifPresent(key -> { + System.out.println("step3: " + key); }); } @@ -63,8 +63,8 @@ public void step3() { public void step4() { Allure.step("step4 nested"); Allure.link("step4", "https://example.org/step4"); - Allure.getLifecycle().getCurrentTestCase().ifPresent(uuid -> { - System.out.println("step4: " + uuid); + Allure.getLifecycle().getCurrentRootKey().ifPresent(key -> { + System.out.println("step4: " + key); }); } @@ -72,8 +72,8 @@ public void step4() { public void step5() { Allure.step("step5 nested"); Allure.link("step5", "https://example.org/step5"); - Allure.getLifecycle().getCurrentTestCase().ifPresent(uuid -> { - System.out.println("step5: " + uuid); + Allure.getLifecycle().getCurrentRootKey().ifPresent(key -> { + System.out.println("step5: " + key); }); } } diff --git a/allure-cucumber7-jvm/src/test/resources/features/datatable.feature b/allure-cucumber7-jvm/src/test/resources/features/datatable.feature index ea5e94d3b..35fe82242 100644 --- a/allure-cucumber7-jvm/src/test/resources/features/datatable.feature +++ b/allure-cucumber7-jvm/src/test/resources/features/datatable.feature @@ -5,4 +5,4 @@ Feature: Test Scenarios with Data Tables | name | login | email | | Viktor | clicman | clicman@ya.ru | | Viktor2 | clicman2 | clicman2@ya.ru | - + | Comma, User | quote "login" | comma@example.org | diff --git a/allure-grpc/src/main/java/io/qameta/allure/grpc/AllureGrpc.java b/allure-grpc/src/main/java/io/qameta/allure/grpc/AllureGrpc.java index fac0fa2ac..f302bb31a 100644 --- a/allure-grpc/src/main/java/io/qameta/allure/grpc/AllureGrpc.java +++ b/allure-grpc/src/main/java/io/qameta/allure/grpc/AllureGrpc.java @@ -27,7 +27,9 @@ import io.grpc.Metadata; import io.grpc.MethodDescriptor; import io.qameta.allure.Allure; +import io.qameta.allure.AllureExternalKey; import io.qameta.allure.AllureLifecycle; +import io.qameta.allure.AttachmentOptions; import io.qameta.allure.http.HttpExchange; import io.qameta.allure.http.HttpExchangeBody; import io.qameta.allure.http.HttpExchangeNameValue; @@ -35,7 +37,6 @@ import io.qameta.allure.http.HttpExchangeResponse; import io.qameta.allure.http.HttpExchangeSerializer; import io.qameta.allure.http.HttpExchangeStream; -import io.qameta.allure.model.Attachment; import io.qameta.allure.model.Status; import io.qameta.allure.model.StepResult; import org.slf4j.Logger; @@ -49,7 +50,7 @@ import java.util.Locale; import java.util.Map; import java.util.Objects; -import java.util.UUID; +import java.util.Optional; import java.util.function.Consumer; /** @@ -138,8 +139,12 @@ public ClientCall interceptCall( final Channel nextChannel) { final Channel channel = Objects.requireNonNull(nextChannel, "nextChannel must not be null"); final AllureLifecycle current = lifecycle; - final String parent = current.getCurrentTestCase().orElse(null); - final String stepUuid = UUID.randomUUID().toString(); + final Optional parent = current.getCurrentExecutableKey(); + if (parent.isEmpty()) { + // no Allure executable is running on this thread — nothing to attach the call to + return channel.newCall(methodDescriptor, callOptions); + } + final AllureExternalKey stepKey = AllureExternalKey.random(AllureGrpc.class); final long start = System.currentTimeMillis(); final List clientMessages = new ArrayList<>(); final List serverMessages = new ArrayList<>(); @@ -148,14 +153,12 @@ public ClientCall interceptCall( final String authority = channel.authority(); final String stepName = buildStepName(channel, methodDescriptor); - if (parent != null) { - current.startStep(parent, stepUuid, new StepResult().setName(stepName)); - } else { - current.startStep(stepUuid, new StepResult().setName(stepName)); - } + // pure manual linkage under the captured parent: it does not bind the current thread, so the step can be + // finalized by key from the gRPC onClose callback on any thread + current.startStep(parent.get(), stepKey, new StepResult().setName(stepName)); final StepContext stepContext = new StepContext<>( - stepUuid, methodDescriptor, current, clientMessages, + stepKey, methodDescriptor, current, clientMessages, serverMessages, initialHeaders, trailers, authority, start ); @@ -209,17 +212,17 @@ private void handleClose( } attachExchange(stepContext, status); stepContext.getLifecycle().updateStep( - stepContext.getStepUuid(), + stepContext.getStepKey(), step -> step.setStatus(convertStatus(status)) ); } catch (Throwable throwable) { LOGGER.error("Failed to finalize Allure step for gRPC call", throwable); stepContext.getLifecycle().updateStep( - stepContext.getStepUuid(), + stepContext.getStepKey(), step -> step.setStatus(Status.BROKEN) ); } finally { - stopStepSafely(stepContext.getLifecycle(), stepContext.getStepUuid()); + stopStepSafely(stepContext.getLifecycle(), stepContext.getStepKey()); } } @@ -271,7 +274,7 @@ private void attachExchange(final StepContext stepContext, final io.grpc.S .setStart(stepContext.getStart()) .setStop(System.currentTimeMillis()) .build(); - addHttpExchangeToStep(stepContext.getStepUuid(), ATTACHMENT_NAME, exchange, stepContext.getLifecycle()); + addHttpExchangeToStep(stepContext.getStepKey(), ATTACHMENT_NAME, exchange, stepContext.getLifecycle()); } private HttpExchange.Builder exchangeBuilder(final HttpExchangeRequest request) { @@ -328,31 +331,24 @@ private HttpExchangeResponse buildResponse( } private void addHttpExchangeToStep( - final String stepUuid, + final AllureExternalKey stepKey, final String attachmentName, final HttpExchange exchange, final AllureLifecycle lifecycle) { - final String source = UUID.randomUUID() + HttpExchange.FILE_EXTENSION; - lifecycle.updateStep( - stepUuid, - step -> step.getAttachments().add( - new Attachment() - .setName(attachmentName) - .setSource(source) - .setType(HttpExchange.CONTENT_TYPE) - ) - ); - lifecycle.writeAttachment( - source, - new ByteArrayInputStream(HttpExchangeSerializer.toJsonBytes(exchange)) + lifecycle.addAttachment( + stepKey, + attachmentName, + HttpExchange.CONTENT_TYPE, + new ByteArrayInputStream(HttpExchangeSerializer.toJsonBytes(exchange)), + AttachmentOptions.empty() ); } - private void stopStepSafely(final AllureLifecycle lifecycle, final String stepUuid) { + private void stopStepSafely(final AllureLifecycle lifecycle, final AllureExternalKey stepKey) { try { - lifecycle.stopStep(stepUuid); + lifecycle.stopStep(stepKey); } catch (Throwable throwable) { - LOGGER.warn("Failed to stop Allure step {}", stepUuid, throwable); + LOGGER.warn("Failed to stop Allure step {}", stepKey, throwable); } } @@ -447,8 +443,12 @@ private static void copyAsciiResponseMetadata( } } + /** + * Per-call mutable state of a reported gRPC call: the Allure step identity, the call metadata, and the + * client/server messages accumulated while the call is in flight. + */ private static final class StepContext { - private final String stepUuid; + private final AllureExternalKey stepKey; private final MethodDescriptor methodDescriptor; private final AllureLifecycle lifecycle; private final List clientMessages; @@ -459,7 +459,7 @@ private static final class StepContext { private final long start; StepContext( - final String stepUuid, + final AllureExternalKey stepKey, final MethodDescriptor methodDescriptor, final AllureLifecycle lifecycle, final List clientMessages, @@ -468,7 +468,7 @@ private static final class StepContext { final Map trailers, final String authority, final long start) { - this.stepUuid = stepUuid; + this.stepKey = stepKey; this.methodDescriptor = methodDescriptor; this.lifecycle = lifecycle; this.clientMessages = clientMessages; @@ -479,8 +479,8 @@ private static final class StepContext { this.start = start; } - String getStepUuid() { - return stepUuid; + AllureExternalKey getStepKey() { + return stepKey; } MethodDescriptor getMethodDescriptor() { diff --git a/allure-grpc/src/test/java/io/qameta/allure/grpc/AllureGrpcTest.java b/allure-grpc/src/test/java/io/qameta/allure/grpc/AllureGrpcTest.java index eb0e9130f..36ee685ad 100644 --- a/allure-grpc/src/test/java/io/qameta/allure/grpc/AllureGrpcTest.java +++ b/allure-grpc/src/test/java/io/qameta/allure/grpc/AllureGrpcTest.java @@ -50,8 +50,10 @@ import static org.grpcmock.GrpcMock.clientStreamingMethod; import static org.grpcmock.GrpcMock.serverStreamingMethod; import static org.grpcmock.GrpcMock.unaryMethod; +import io.qameta.allure.test.IsolatedLifecycle; @ExtendWith(GrpcMockExtension.class) +@IsolatedLifecycle class AllureGrpcTest { private static final String RESPONSE_MESSAGE = "Hello world!"; diff --git a/allure-hamcrest/build.gradle.kts b/allure-hamcrest/build.gradle.kts index 79232d8cf..2ad7e8d3f 100644 --- a/allure-hamcrest/build.gradle.kts +++ b/allure-hamcrest/build.gradle.kts @@ -10,6 +10,7 @@ dependencies { testImplementation("org.junit.jupiter:junit-jupiter-params") testImplementation("org.slf4j:slf4j-simple") testImplementation(project(":allure-assertj")) + testImplementation(project(":allure-junit-platform")) testImplementation(project(":allure-java-commons-test")) testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine") testRuntimeOnly("org.junit.platform:junit-platform-launcher") diff --git a/allure-hamcrest/src/main/java/io/qameta/allure/hamcrest/AllureHamcrestAssert.java b/allure-hamcrest/src/main/java/io/qameta/allure/hamcrest/AllureHamcrestAssert.java index 99d60fd9f..9b40d38f6 100644 --- a/allure-hamcrest/src/main/java/io/qameta/allure/hamcrest/AllureHamcrestAssert.java +++ b/allure-hamcrest/src/main/java/io/qameta/allure/hamcrest/AllureHamcrestAssert.java @@ -29,8 +29,6 @@ import org.hamcrest.Matcher; import org.hamcrest.StringDescription; -import java.util.UUID; - import static io.qameta.allure.util.ResultsUtils.getStatus; /** @@ -53,20 +51,13 @@ @SuppressWarnings("all") public class AllureHamcrestAssert { - private static InheritableThreadLocal lifecycle = new InheritableThreadLocal() { - @Override - protected AllureLifecycle initialValue() { - return Allure.getLifecycle(); - } - }; - /** * Returns the lifecycle. * * @return the Allure lifecycle used by this integration */ public static AllureLifecycle getLifecycle() { - return lifecycle.get(); + return Allure.getLifecycle(); } /** @@ -93,6 +84,11 @@ public void initAssertThat() { */ @Before("initAssertThat()") public void catchAndStartStep(final JoinPoint joinPoint) { + // enrichment-only integration: silently skip when no executable is running, + // so a disabled Allure reporter produces no warnings and no wasted work + if (getLifecycle().getCurrentExecutableKey().isEmpty()) { + return; + } if (joinPoint.getArgs().length == 3) { final String reason = (String) joinPoint.getArgs()[0]; final String actual = ObjectUtils.toString(joinPoint.getArgs()[1]); @@ -104,7 +100,6 @@ public void catchAndStartStep(final JoinPoint joinPoint) { .toString(); getLifecycle().startStep( - UUID.randomUUID().toString(), new StepResult() .setName(reason.isEmpty() ? expecting : expecting + " | " + reason) .setDescription("Hamcrest assert") @@ -124,6 +119,9 @@ public void catchAndStartStep(final JoinPoint joinPoint) { * @param e the e */ public void stepFailed(final Throwable e) { + if (getLifecycle().getCurrentExecutableKey().isEmpty()) { + return; + } getLifecycle().updateStep(s -> s.setStatus(getStatus(e).orElse(Status.BROKEN))); getLifecycle().stopStep(); } @@ -133,16 +131,11 @@ public void stepFailed(final Throwable e) { */ @AfterReturning(pointcut = "initAssertThat()") public void stepStop() { + if (getLifecycle().getCurrentExecutableKey().isEmpty()) { + return; + } getLifecycle().updateStep(s -> s.setStatus(Status.PASSED)); getLifecycle().stopStep(); } - /** - * For tests only. - * - * @param allure allure lifecycle to set - */ - public static void setLifecycle(final AllureLifecycle allure) { - lifecycle.set(allure); - } } diff --git a/allure-hamcrest/src/test/java/io/qameta/allure/hamcrest/AllureHamcrestAssertionNameContainsReasonTest.java b/allure-hamcrest/src/test/java/io/qameta/allure/hamcrest/AllureHamcrestAssertionNameContainsReasonTest.java index bc945a66e..88100d360 100644 --- a/allure-hamcrest/src/test/java/io/qameta/allure/hamcrest/AllureHamcrestAssertionNameContainsReasonTest.java +++ b/allure-hamcrest/src/test/java/io/qameta/allure/hamcrest/AllureHamcrestAssertionNameContainsReasonTest.java @@ -24,12 +24,14 @@ import static io.qameta.allure.test.RunUtils.runWithinTestContext; import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.equalToIgnoringCase; +import io.qameta.allure.test.IsolatedLifecycle; /** * This tests should cover cases when reason string exists in assertion. */ @SuppressWarnings("all") @TestInstance(TestInstance.Lifecycle.PER_METHOD) +@IsolatedLifecycle public class AllureHamcrestAssertionNameContainsReasonTest { @Test @@ -39,8 +41,7 @@ void hamcrestAssertNameWithComment() { "Business always likes something weird", "TheBiscuit", equalToIgnoringCase("thebiscuit") - ), - AllureHamcrestAssert::setLifecycle + ) ).getTestResults().get(0); Assertions.assertThat(testResult.getSteps()) @@ -54,8 +55,7 @@ void hamcrestAssertNameWoComment() { () -> assertThat( "TheBiscuit", equalToIgnoringCase("thebiscuit") - ), - AllureHamcrestAssert::setLifecycle + ) ).getTestResults().get(0); Assertions.assertThat(testResult.getSteps()) diff --git a/allure-hamcrest/src/test/java/io/qameta/allure/hamcrest/AllureHamcrestCollectionsMatchersTest.java b/allure-hamcrest/src/test/java/io/qameta/allure/hamcrest/AllureHamcrestCollectionsMatchersTest.java index acdd8900c..de83d01bb 100644 --- a/allure-hamcrest/src/test/java/io/qameta/allure/hamcrest/AllureHamcrestCollectionsMatchersTest.java +++ b/allure-hamcrest/src/test/java/io/qameta/allure/hamcrest/AllureHamcrestCollectionsMatchersTest.java @@ -34,6 +34,7 @@ import static io.qameta.allure.test.RunUtils.runWithinTestContext; import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.*; +import io.qameta.allure.test.IsolatedLifecycle; /** * All tests should cover http://hamcrest.org/JavaHamcrest/tutorial "Collections" section @@ -49,13 +50,13 @@ */ @SuppressWarnings("all") @TestInstance(TestInstance.Lifecycle.PER_METHOD) +@IsolatedLifecycle public class AllureHamcrestCollectionsMatchersTest { @Test void hamcrestAssertNameForArrayMatchers() { final TestResult testResult = runWithinTestContext( - () -> assertThat(new Integer[]{1, 2, 3}, is(array(equalTo(1), equalTo(2), equalTo(3)))), - AllureHamcrestAssert::setLifecycle + () -> assertThat(new Integer[]{1, 2, 3}, is(array(equalTo(1), equalTo(2), equalTo(3)))) ).getTestResults().get(0); Assertions.assertThat(testResult.getSteps()) @@ -89,8 +90,7 @@ map, hasValue(equalTo(3)), @MethodSource("mapTestCases") void hamcrestAssertNameForMapMatchers(Map actual, Matcher matcher, String expectedName) { final TestResult testResult = runWithinTestContext( - () -> assertThat(actual, matcher), - AllureHamcrestAssert::setLifecycle + () -> assertThat(actual, matcher) ).getTestResults().get(0); Assertions.assertThat(testResult.getSteps()) @@ -121,8 +121,7 @@ list, hasItemInArray(Arrays.asList("val1", "val2")), @MethodSource("iterableTestCases") void hamcrestAssertNameForIterableMatchers(Iterable actual, Matcher matcher, String expectedName) { final TestResult testResult = runWithinTestContext( - () -> assertThat(actual, matcher), - AllureHamcrestAssert::setLifecycle + () -> assertThat(actual, matcher) ).getTestResults().get(0); Assertions.assertThat(testResult.getSteps()) diff --git a/allure-hamcrest/src/test/java/io/qameta/allure/hamcrest/AllureHamcrestLogicalMatchersTest.java b/allure-hamcrest/src/test/java/io/qameta/allure/hamcrest/AllureHamcrestLogicalMatchersTest.java index 6e986f47f..768917606 100644 --- a/allure-hamcrest/src/test/java/io/qameta/allure/hamcrest/AllureHamcrestLogicalMatchersTest.java +++ b/allure-hamcrest/src/test/java/io/qameta/allure/hamcrest/AllureHamcrestLogicalMatchersTest.java @@ -29,6 +29,7 @@ import static io.qameta.allure.test.RunUtils.runWithinTestContext; import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.*; +import io.qameta.allure.test.IsolatedLifecycle; /** * All tests should cover http://hamcrest.org/JavaHamcrest/tutorial "Logical" section @@ -40,6 +41,7 @@ */ @SuppressWarnings("all") @TestInstance(TestInstance.Lifecycle.PER_METHOD) +@IsolatedLifecycle public class AllureHamcrestLogicalMatchersTest { private static Stream testCases() { @@ -63,8 +65,7 @@ private static Stream testCases() { @MethodSource("testCases") void hamcrestAssertNameForLogicalMatchers(String actual, Matcher matcher, String expectedName) { final TestResult testResult = runWithinTestContext( - () -> assertThat(actual, matcher), - AllureHamcrestAssert::setLifecycle + () -> assertThat(actual, matcher) ).getTestResults().get(0); Assertions.assertThat(testResult.getSteps()) diff --git a/allure-hamcrest/src/test/java/io/qameta/allure/hamcrest/AllureHamcrestNoContextTest.java b/allure-hamcrest/src/test/java/io/qameta/allure/hamcrest/AllureHamcrestNoContextTest.java new file mode 100644 index 000000000..89ea504d9 --- /dev/null +++ b/allure-hamcrest/src/test/java/io/qameta/allure/hamcrest/AllureHamcrestNoContextTest.java @@ -0,0 +1,42 @@ +/* + * 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.hamcrest; + +import io.qameta.allure.test.AllureResults; +import org.hamcrest.MatcherAssert; +import org.hamcrest.Matchers; +import org.junit.jupiter.api.Test; + +import static io.qameta.allure.test.RunUtils.runTests; +import static org.assertj.core.api.Assertions.assertThat; +import io.qameta.allure.test.IsolatedLifecycle; + +/** + * Hamcrest asserts are enrichment-only: with no Allure executable running (for example when the reporter is + * disabled), the aspect must skip silently — no step results, no warnings, and the assert itself still executes. + */ +@IsolatedLifecycle +class AllureHamcrestNoContextTest { + + @Test + void shouldSkipSilentlyWithoutTestContext() { + final AllureResults results = runTests(lifecycle -> + MatcherAssert.assertThat("the assert still runs", Matchers.notNullValue())); + + assertThat(results.getTestResults()).isEmpty(); + assertThat(results.getTestResultContainers()).isEmpty(); + } +} diff --git a/allure-hamcrest/src/test/java/io/qameta/allure/hamcrest/AllureHamcrestNumberMatchersTest.java b/allure-hamcrest/src/test/java/io/qameta/allure/hamcrest/AllureHamcrestNumberMatchersTest.java index 86d72532f..3175713b2 100644 --- a/allure-hamcrest/src/test/java/io/qameta/allure/hamcrest/AllureHamcrestNumberMatchersTest.java +++ b/allure-hamcrest/src/test/java/io/qameta/allure/hamcrest/AllureHamcrestNumberMatchersTest.java @@ -29,6 +29,7 @@ import static io.qameta.allure.test.RunUtils.runWithinTestContext; import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.*; +import io.qameta.allure.test.IsolatedLifecycle; /** * All tests should cover http://hamcrest.org/JavaHamcrest/tutorial "Number" section @@ -42,6 +43,7 @@ */ @SuppressWarnings("all") @TestInstance(TestInstance.Lifecycle.PER_METHOD) +@IsolatedLifecycle public class AllureHamcrestNumberMatchersTest { private static Stream testCases() { @@ -73,8 +75,7 @@ private static Stream testCases() { @MethodSource("testCases") void hamcrestAssertNameForNumberMatchers(double actual, Matcher matcher, String expectedName) { final TestResult testResult = runWithinTestContext( - () -> assertThat(actual, matcher), - AllureHamcrestAssert::setLifecycle + () -> assertThat(actual, matcher) ).getTestResults().get(0); Assertions.assertThat(testResult.getSteps()) diff --git a/allure-hamcrest/src/test/java/io/qameta/allure/hamcrest/AllureHamcrestObjectMatchersTest.java b/allure-hamcrest/src/test/java/io/qameta/allure/hamcrest/AllureHamcrestObjectMatchersTest.java index beeb7fd4f..01f026b15 100644 --- a/allure-hamcrest/src/test/java/io/qameta/allure/hamcrest/AllureHamcrestObjectMatchersTest.java +++ b/allure-hamcrest/src/test/java/io/qameta/allure/hamcrest/AllureHamcrestObjectMatchersTest.java @@ -30,6 +30,7 @@ import static io.qameta.allure.test.RunUtils.runWithinTestContext; import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.*; +import io.qameta.allure.test.IsolatedLifecycle; /** * All tests should cover http://hamcrest.org/JavaHamcrest/tutorial "Object" section @@ -45,6 +46,7 @@ */ @SuppressWarnings("all") @TestInstance(TestInstance.Lifecycle.PER_METHOD) +@IsolatedLifecycle public class AllureHamcrestObjectMatchersTest { private static Stream testCases() { @@ -86,8 +88,7 @@ testVal, is(sameInstance(testVal)), @MethodSource("testCases") void hamcrestAssertNameForObjectMatchers(Object actual, Matcher matcher, String expectedName) { final TestResult testResult = runWithinTestContext( - () -> assertThat(actual, matcher), - AllureHamcrestAssert::setLifecycle + () -> assertThat(actual, matcher) ).getTestResults().get(0); Assertions.assertThat(testResult.getSteps()) diff --git a/allure-hamcrest/src/test/java/io/qameta/allure/hamcrest/AllureHamcrestTextMatchersTest.java b/allure-hamcrest/src/test/java/io/qameta/allure/hamcrest/AllureHamcrestTextMatchersTest.java index 515f6ecbb..07ad2f9ea 100644 --- a/allure-hamcrest/src/test/java/io/qameta/allure/hamcrest/AllureHamcrestTextMatchersTest.java +++ b/allure-hamcrest/src/test/java/io/qameta/allure/hamcrest/AllureHamcrestTextMatchersTest.java @@ -29,6 +29,7 @@ import static io.qameta.allure.test.RunUtils.runWithinTestContext; import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.*; +import io.qameta.allure.test.IsolatedLifecycle; /** * All tests should cover http://hamcrest.org/JavaHamcrest/tutorial "Text" section @@ -42,6 +43,7 @@ */ @SuppressWarnings("all") @TestInstance(TestInstance.Lifecycle.PER_METHOD) +@IsolatedLifecycle public class AllureHamcrestTextMatchersTest { private static Stream testCases() { @@ -77,8 +79,7 @@ private static Stream testCases() { @MethodSource("testCases") void hamcrestAssertNameForTextMatchers(String actual, Matcher matcher, String expectedName) { final TestResult testResult = runWithinTestContext( - () -> assertThat(actual, matcher), - AllureHamcrestAssert::setLifecycle + () -> assertThat(actual, matcher) ).getTestResults().get(0); Assertions.assertThat(testResult.getSteps()) diff --git a/allure-httpclient/src/main/java/io/qameta/allure/httpclient/AllureHttpClientRequest.java b/allure-httpclient/src/main/java/io/qameta/allure/httpclient/AllureHttpClientRequest.java index 5b4f99e92..e79d3ee4e 100644 --- a/allure-httpclient/src/main/java/io/qameta/allure/httpclient/AllureHttpClientRequest.java +++ b/allure-httpclient/src/main/java/io/qameta/allure/httpclient/AllureHttpClientRequest.java @@ -15,6 +15,7 @@ */ package io.qameta.allure.httpclient; +import io.qameta.allure.Allure; import io.qameta.allure.http.HttpExchangeBody; import io.qameta.allure.http.HttpExchangeRequest; import org.apache.http.Header; @@ -46,6 +47,11 @@ public class AllureHttpClientRequest implements HttpRequestInterceptor { public void process(final HttpRequest request, final HttpContext context) throws IOException { + // enrichment-only integration: silently skip when no executable is running, + // so a disabled Allure reporter produces no warnings and no body copying + if (Allure.getLifecycle().getCurrentExecutableKey().isEmpty()) { + return; + } final HttpExchangeRequest.Builder builder = HttpExchangeRequest .builder(request.getRequestLine().getMethod(), request.getRequestLine().getUri()); diff --git a/allure-httpclient/src/main/java/io/qameta/allure/httpclient/AllureHttpClientResponse.java b/allure-httpclient/src/main/java/io/qameta/allure/httpclient/AllureHttpClientResponse.java index 7de52a82f..0444e47af 100644 --- a/allure-httpclient/src/main/java/io/qameta/allure/httpclient/AllureHttpClientResponse.java +++ b/allure-httpclient/src/main/java/io/qameta/allure/httpclient/AllureHttpClientResponse.java @@ -63,6 +63,11 @@ public AllureHttpClientResponse configureHttpExchange(final Consumer { try { - Allure.addAttachment( + Allure.attachment( testResult.getUuid() + AllureConstants.TEST_RESULT_FILE_SUFFIX, JSON_TYPE, - WRITER.writeValueAsString(testResult), - JSON_EXTENSION + WRITER.writeValueAsString(testResult) ); } catch (JsonProcessingException e) { throw new UncheckedIOException(e); @@ -84,11 +84,10 @@ public static void attach(final AllureResults allureResults) { allureResults.getTestResultContainers().forEach(container -> { try { - Allure.addAttachment( + Allure.attachment( container.getUuid() + AllureConstants.TEST_RESULT_CONTAINER_FILE_SUFFIX, JSON_TYPE, - WRITER.writeValueAsString(container), - JSON_EXTENSION + WRITER.writeValueAsString(container) ); } catch (JsonProcessingException e) { throw new UncheckedIOException(e); @@ -97,15 +96,22 @@ public static void attach(final AllureResults allureResults) { allureResults.getAttachments().forEach( (fileName, body) -> Allure - .addAttachment( + .attachment( fileName, type(fileName), new ByteArrayInputStream(body), - extension(fileName) + attachmentOptions(fileName) ) ); } + private static AttachmentOptions attachmentOptions(final String fileName) { + if (fileName.endsWith(DOT + JSON_EXTENSION) || fileName.endsWith(DOT + TEXT_EXTENSION)) { + return AttachmentOptions.empty(); + } + return AttachmentOptions.withFileExtension(extension(fileName)); + } + private static String type(final String fileName) { if (fileName.endsWith(DOT + JSON_EXTENSION)) { return JSON_TYPE; diff --git a/allure-java-commons-test/src/main/java/io/qameta/allure/test/IsolatedLifecycle.java b/allure-java-commons-test/src/main/java/io/qameta/allure/test/IsolatedLifecycle.java new file mode 100644 index 000000000..ffb0b9e92 --- /dev/null +++ b/allure-java-commons-test/src/main/java/io/qameta/allure/test/IsolatedLifecycle.java @@ -0,0 +1,43 @@ +/* + * 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.test; + +import org.junit.jupiter.api.parallel.ResourceLock; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Inherited; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Marks tests that run an isolated Allure lifecycle through {@link RunUtils}. The harness swaps the process-wide + * lifecycle so the facade is exercised exactly as in production — same instance from any thread — which means such + * tests must never run concurrently with each other. This composed {@link ResourceLock} lets the JUnit Platform + * schedule them exclusively while unrelated tests stay parallel. Every test class using {@link RunUtils} (directly + * or through a module harness) must carry this annotation. + */ +@Inherited +@Retention(RetentionPolicy.RUNTIME) +@Target({ElementType.TYPE, ElementType.METHOD}) +@ResourceLock(IsolatedLifecycle.RESOURCE) +public @interface IsolatedLifecycle { + + /** + * The resource name representing the process-wide Allure lifecycle. + */ + String RESOURCE = "io.qameta.allure.Allure.lifecycle"; +} diff --git a/allure-java-commons-test/src/main/java/io/qameta/allure/test/RunUtils.java b/allure-java-commons-test/src/main/java/io/qameta/allure/test/RunUtils.java index 62e2c8d8b..a5eab48b3 100644 --- a/allure-java-commons-test/src/main/java/io/qameta/allure/test/RunUtils.java +++ b/allure-java-commons-test/src/main/java/io/qameta/allure/test/RunUtils.java @@ -16,10 +16,9 @@ package io.qameta.allure.test; import io.qameta.allure.Allure; +import io.qameta.allure.AllureExternalKey; import io.qameta.allure.AllureLifecycle; import io.qameta.allure.AllureResultsWriter; -import io.qameta.allure.aspects.AttachmentsAspects; -import io.qameta.allure.aspects.StepsAspects; import io.qameta.allure.model.TestResult; import io.qameta.allure.util.ExceptionUtils; @@ -45,43 +44,10 @@ private RunUtils() { /** * Runs the supplied tests and returns collected Allure results. * - * @param runnable the runnable - * @return the collected Allure results - */ - public static AllureResults runTests( - final Allure.ThrowableContextRunnableVoid runnable) { - return runTests( - runnable, - Allure::setLifecycle, - StepsAspects::setLifecycle, - AttachmentsAspects::setLifecycle - ); - } - - /** - * Runs the supplied tests and returns collected Allure results. - * - * @param lifecycleFactory the lifecycle factory - * @param runnable the runnable - * @return the collected Allure results - */ - public static AllureResults runTests( - final Function lifecycleFactory, - final Allure.ThrowableContextRunnableVoid runnable) { - return runTests( - lifecycleFactory, - runnable, - Allure::setLifecycle, - StepsAspects::setLifecycle, - AttachmentsAspects::setLifecycle - ); - } - - /** - * Runs the supplied tests and returns collected Allure results. - * - * @param runnable the runnable - * @param configurers the configurers + * @param runnable the runnable + * @param configurers the configurers exposing the stub lifecycle to the integration under test — for + * integrations that do not resolve {@code Allure.getLifecycle()} at call time; each is + * called with the stub before the run and with the previous lifecycle after it * @return the collected Allure results */ @SafeVarargs @@ -95,8 +61,10 @@ public static AllureResults runTests( * Runs the supplied tests and returns collected Allure results. * * @param lifecycleFactory the lifecycle factory - * @param runnable the runnable - * @param configurers the configurers + * @param runnable the runnable + * @param configurers the configurers exposing the stub lifecycle to the integration under test — for + * integrations that do not resolve {@code Allure.getLifecycle()} at call time; each is + * called with the stub before the run and with the previous lifecycle after it * @return the collected Allure results */ @SafeVarargs @@ -108,18 +76,21 @@ public static AllureResults runTests( final AllureResultsWriterStub writer = new AllureResultsWriterStub(); final AllureLifecycle lifecycle = lifecycleFactory.apply(writer); - final AllureLifecycle defaultLifecycle = Allure.getLifecycle(); + // swaps the process-wide lifecycle so the facade runs exactly as in production; callers must + // carry @IsolatedLifecycle so the platform never schedules two such runs concurrently + final AllureLifecycle previous = Allure.getLifecycle(); + Allure.setLifecycle(lifecycle); + Stream.of(configurers).forEach(configurer -> configurer.accept(lifecycle)); try { - Stream.of(configurers).forEach(configurer -> configurer.accept(lifecycle)); - runnable.run(lifecycle); - return writer; } catch (Throwable e) { throw ExceptionUtils.sneakyThrow(e); } finally { - Stream.of(configurers).forEach(configurer -> configurer.accept(defaultLifecycle)); - + // restore in reverse: integration wiring first, then the process-wide lifecycle, so no + // stub reference survives the run + Stream.of(configurers).forEach(configurer -> configurer.accept(previous)); + Allure.setLifecycle(previous); AllureTestCommonsUtils.attach(writer); } }); @@ -128,32 +99,8 @@ public static AllureResults runTests( /** * Runs the callback inside an Allure test context and returns collected results. * - * @param runnable the runnable - * @return the collected Allure results - */ - public static AllureResults runWithinTestContext( - final Runnable runnable) { - return runTests(lifecycle -> withTestContext(runnable, lifecycle)); - } - - /** - * Runs the callback inside an Allure test context and returns collected results. - * - * @param lifecycleFactory the lifecycle factory - * @param runnable the runnable - * @return the collected Allure results - */ - public static AllureResults runWithinTestContext( - final Function lifecycleFactory, - final Runnable runnable) { - return runTests(lifecycleFactory, lifecycle -> withTestContext(runnable, lifecycle)); - } - - /** - * Runs the callback inside an Allure test context and returns collected results. - * - * @param runnable the runnable - * @param configurers the configurers + * @param runnable the runnable + * @param configurers the configurers exposing the stub lifecycle to the integration under test * @return the collected Allure results */ @SafeVarargs @@ -167,8 +114,8 @@ public static AllureResults runWithinTestContext( * Runs the callback inside an Allure test context and returns collected results. * * @param lifecycleFactory the lifecycle factory - * @param runnable the runnable - * @param configurers the configurers + * @param runnable the runnable + * @param configurers the configurers exposing the stub lifecycle to the integration under test * @return the collected Allure results */ @SafeVarargs @@ -182,21 +129,22 @@ public static AllureResults runWithinTestContext( private static void withTestContext(final Runnable runnable, final AllureLifecycle lifecycle) { final String uuid = UUID.randomUUID().toString(); final TestResult result = new TestResult().setUuid(uuid); + final AllureExternalKey testKey = AllureExternalKey.random(RunUtils.class); try { - lifecycle.scheduleTestCase(result); - lifecycle.startTestCase(uuid); + lifecycle.scheduleTest(testKey, result); + lifecycle.startTest(testKey); runnable.run(); } catch (Throwable e) { - lifecycle.updateTestCase(uuid, testResult -> { + lifecycle.updateTest(testKey, testResult -> { getStatus(e).ifPresent(testResult::setStatus); getStatusDetails(e).ifPresent(testResult::setStatusDetails); }); } finally { - lifecycle.stopTestCase(uuid); - lifecycle.writeTestCase(uuid); + lifecycle.stopTest(testKey); + lifecycle.writeTest(testKey); } } diff --git a/allure-java-commons-test/src/test/java/io/qameta/allure/test/RunUtilsTest.java b/allure-java-commons-test/src/test/java/io/qameta/allure/test/RunUtilsTest.java index 0de6547fa..07db30658 100644 --- a/allure-java-commons-test/src/test/java/io/qameta/allure/test/RunUtilsTest.java +++ b/allure-java-commons-test/src/test/java/io/qameta/allure/test/RunUtilsTest.java @@ -23,7 +23,9 @@ import java.nio.charset.StandardCharsets; import static org.assertj.core.api.Assertions.assertThat; +import io.qameta.allure.test.IsolatedLifecycle; +@IsolatedLifecycle class RunUtilsTest { @Test @@ -53,7 +55,7 @@ void shouldAttachNestedRunArtifactsToOuterLifecycle() { ) ); - Allure.addAttachment("nested-attachment-keys", String.join("\n", results.getAttachments().keySet())); + Allure.attachment("nested-attachment-keys", String.join("\n", results.getAttachments().keySet())); Allure.step("Verify the outer lifecycle receives serialized artifacts from the nested run", () -> { assertThat(results.getAttachments()) .isNotEmpty(); diff --git a/allure-java-commons-test/src/test/java/io/qameta/allure/test/TestUtilitiesTest.java b/allure-java-commons-test/src/test/java/io/qameta/allure/test/TestUtilitiesTest.java index d397f3402..5e7f4fbc1 100644 --- a/allure-java-commons-test/src/test/java/io/qameta/allure/test/TestUtilitiesTest.java +++ b/allure-java-commons-test/src/test/java/io/qameta/allure/test/TestUtilitiesTest.java @@ -36,7 +36,7 @@ void shouldGenerateStableThreadLocalRandomPerThread() throws Exception { Allure.step("Resolve thread-local random generators on two threads and compare their identities", () -> { thread.start(); thread.join(); - Allure.addAttachment( + Allure.attachment( "thread-local-random-identities", "main=" + System.identityHashCode(mainThread) + "\nworker=" + System.identityHashCode(workerThread.get()) diff --git a/allure-java-commons/README.md b/allure-java-commons/README.md index b154cb4b6..06b8b65f7 100644 --- a/allure-java-commons/README.md +++ b/allure-java-commons/README.md @@ -39,7 +39,7 @@ Use `io.qameta.allure.Allure` for high-level steps, attachments, labels, links, import io.qameta.allure.Allure; Allure.step("Create order", () -> { - Allure.addAttachment("request-id", "42"); + Allure.attachment("request-id", "42"); }); ``` @@ -86,4 +86,4 @@ The builder applies redaction and truncation before the exchange is attached. Co ## What To Expect -This module writes result data only when your code or an adapter calls the runtime API. In ordinary test suites, add a framework adapter first and use `Allure.step(...)`, `Allure.addAttachment(...)`, and metadata annotations for extra report detail. +This module writes result data only when your code or an adapter calls the runtime API. In ordinary test suites, add a framework adapter first and use `Allure.step(...)`, `Allure.attachment(...)`, and metadata annotations for extra report detail. diff --git a/allure-java-commons/src/main/java/io/qameta/allure/Allure.java b/allure-java-commons/src/main/java/io/qameta/allure/Allure.java index 527f5a859..06efbf8fd 100644 --- a/allure-java-commons/src/main/java/io/qameta/allure/Allure.java +++ b/allure-java-commons/src/main/java/io/qameta/allure/Allure.java @@ -17,7 +17,6 @@ import io.qameta.allure.http.HttpExchange; import io.qameta.allure.http.HttpExchangeSerializer; -import io.qameta.allure.listener.LifecycleNotifier; import io.qameta.allure.model.Label; import io.qameta.allure.model.Link; import io.qameta.allure.model.Parameter; @@ -29,9 +28,8 @@ import java.io.InputStream; import java.nio.charset.StandardCharsets; import java.util.Objects; -import java.util.UUID; import java.util.concurrent.CompletableFuture; -import java.util.function.Supplier; +import java.util.concurrent.CompletionStage; import static io.qameta.allure.util.ResultsUtils.EPIC_LABEL_NAME; import static io.qameta.allure.util.ResultsUtils.FEATURE_LABEL_NAME; @@ -42,15 +40,13 @@ import static io.qameta.allure.util.ResultsUtils.createParameter; import static io.qameta.allure.util.ResultsUtils.getStatus; import static io.qameta.allure.util.ResultsUtils.getStatusDetails; -import static java.util.concurrent.CompletableFuture.supplyAsync; /** * The class contains some useful methods to work with {@link AllureLifecycle}. */ -@SuppressWarnings({"PMD.TooManyMethods", "PMD.GodClass"}) +@SuppressWarnings("PMD.TooManyMethods") public final class Allure { - private static final String TXT_EXTENSION = ".txt"; private static final String TEXT_PLAIN = "text/plain"; private static AllureLifecycle lifecycle; @@ -75,7 +71,7 @@ public static AllureLifecycle getLifecycle() { } /** - * Sets {@link AllureLifecycle}. + * Sets the process-wide {@link AllureLifecycle}. */ public static void setLifecycle(final AllureLifecycle lifecycle) { Allure.lifecycle = lifecycle; @@ -99,9 +95,7 @@ public static void step(final String name) { * @param status the step status. */ public static void step(final String name, final Status status) { - final String uuid = UUID.randomUUID().toString(); - getLifecycle().startStep(uuid, new StepResult().setName(name).setStatus(status)); - getLifecycle().stopStep(uuid); + getLifecycle().logStep(new StepResult().setName(name).setStatus(status)); } /** @@ -176,25 +170,49 @@ public static T step(final String name, final ThrowableContextRunnable T step(final ThrowableContextRunnable runnable) { - final String uuid = UUID.randomUUID().toString(); - getLifecycle().startStep(uuid, new StepResult().setName("step")); + final AllureExternalKey key = AllureExternalKey.random(Allure.class); + getLifecycle().startStep(key, new StepResult().setName("step")); try { - final T result = runnable.run(new DefaultStepContext(uuid)); - getLifecycle().updateStep(uuid, step -> step.setStatus(Status.PASSED)); + final T result = runnable.run(new DefaultStepContext(key)); + getLifecycle().updateStep(key, step -> step.setStatus(Status.PASSED)); return result; } catch (Throwable throwable) { getLifecycle().updateStep( + key, s -> s .setStatus(getStatus(throwable).orElse(Status.BROKEN)) .setStatusDetails(getStatusDetails(throwable).orElse(null)) ); throw ExceptionUtils.sneakyThrow(throwable); } finally { - getLifecycle().stopStep(uuid); + getLifecycle().stopStep(); } } + /** + * Starts a stage — a lightweight marker for a semantic test phase, rendered as a regular step. A stage has no + * explicit stop: it stays open, collecting the steps and attachments that follow, until the next stage starts + * or the enclosing step, test, or fixture ends. A stage started inside a step becomes a child of that step. + * Takes no effect if no test run at the moment. + * + *

+     * Allure.stage("prepare data");
+     * final Customer customer = createCustomer();
+     *
+     * Allure.stage("submit order");
+     * final Order order = submitOrder(customer);
+     *
+     * Allure.stage("verify result");
+     * assertThat(order.getStatus()).isEqualTo("created");
+     * 
+ * + * @param name the name of the stage. + */ + public static void stage(final String name) { + getLifecycle().startStage(new StepResult().setName(name)); + } + /** * Adds epic label to current test if any. Takes no effect * if no test run at the moment. Shortcut for {@link #label(String, String)}. @@ -244,7 +262,7 @@ public static void suite(final String value) { */ public static void label(final String name, final String value) { final Label label = new Label().setName(name).setValue(value); - getLifecycle().addLabel(label); + getLifecycle().updateTestMetadata(metadata -> metadata.getLabels().add(label)); } /** @@ -304,7 +322,7 @@ public static T parameter(final String name, final T value, public static T parameter(final String name, final T value, final Boolean excluded, final Parameter.Mode mode) { final Parameter parameter = createParameter(name, value, excluded, mode); - getLifecycle().addParameter(parameter); + getLifecycle().updateTestMetadata(metadata -> metadata.getParameters().add(parameter)); return value; } @@ -361,7 +379,7 @@ public static void link(final String name, final String url) { */ public static void link(final String name, final String type, final String url) { final Link link = new Link().setName(name).setType(type).setUrl(url); - getLifecycle().addLink(link); + getLifecycle().updateTestMetadata(metadata -> metadata.getLinks().add(link)); } /** @@ -372,7 +390,7 @@ public static void link(final String name, final String type, final String url) * @see #descriptionHtml(String) */ public static void description(final String description) { - getLifecycle().setDescription(description); + getLifecycle().updateTestMetadata(metadata -> metadata.setDescription(description)); } /** @@ -384,7 +402,7 @@ public static void description(final String description) { * @see #description(String) */ public static void descriptionHtml(final String descriptionHtml) { - getLifecycle().setDescriptionHtml(descriptionHtml); + getLifecycle().updateTestMetadata(metadata -> metadata.setDescriptionHtml(descriptionHtml)); } /** @@ -394,7 +412,12 @@ public static void descriptionHtml(final String descriptionHtml) { * @param content the attachment content. */ public static void attachment(final String name, final String content) { - addAttachment(name, content); + addAttachmentAsStep( + name, + TEXT_PLAIN, + content.getBytes(StandardCharsets.UTF_8), + AttachmentOptions.empty() + ); } /** @@ -404,65 +427,78 @@ public static void attachment(final String name, final String content) { * @param content the stream that contains attachment content. */ public static void attachment(final String name, final InputStream content) { - addAttachment(name, content); + addAttachmentAsStep(name, null, content, AttachmentOptions.empty()); } /** - * Adds the attachment. + * Adds attachment. * - * @param name the display name or logical name to use - * @param content the attachment content + * @param name the name of attachment. + * @param type the content type of attachment. + * @param content the attachment content. */ - public static void addAttachment(final String name, final String content) { - addAttachmentAsStep(name, TEXT_PLAIN, TXT_EXTENSION, content.getBytes(StandardCharsets.UTF_8)); + public static void attachment(final String name, final String type, final String content) { + addAttachmentAsStep( + name, + type, + content.getBytes(StandardCharsets.UTF_8), + AttachmentOptions.empty() + ); } /** - * Adds the attachment. + * Adds attachment. * - * @param name the display name or logical name to use - * @param type the event or label type - * @param content the attachment content + * @param name the name of attachment. + * @param type the content type of attachment. + * @param content the attachment content. + * @param options the attachment options. */ - public static void addAttachment(final String name, final String type, final String content) { - addAttachmentAsStep(name, type, TXT_EXTENSION, content.getBytes(StandardCharsets.UTF_8)); + public static void attachment(final String name, final String type, + final String content, final AttachmentOptions options) { + addAttachmentAsStep(name, type, content.getBytes(StandardCharsets.UTF_8), options); } /** - * Adds the attachment. + * Adds attachment. * - * @param name the display name or logical name to use - * @param type the event or label type - * @param content the attachment content - * @param fileExtension the attachment file extension + * @param name the name of attachment. + * @param type the content type of attachment. + * @param content the stream that contains attachment content. + * @param options the attachment options. */ - @SuppressWarnings("PMD.UseObjectForClearerAPI") - public static void addAttachment(final String name, final String type, - final String content, final String fileExtension) { - addAttachmentAsStep(name, type, fileExtension, content.getBytes(StandardCharsets.UTF_8)); + public static void attachment(final String name, final String type, + final InputStream content, final AttachmentOptions options) { + addAttachmentAsStep(name, type, content, options); } /** - * Adds the attachment. + * Adds an async attachment and waits for its content before the owning executable ends. * - * @param name the display name or logical name to use - * @param content the attachment content + * @param name the name of attachment. + * @param type the content type of attachment. + * @param body the future stream that contains attachment content. + * @return future completed when attachment content is written. */ - public static void addAttachment(final String name, final InputStream content) { - addAttachmentAsStep(name, null, null, content); + public static CompletableFuture attachmentAsync( + final String name, final String type, final CompletionStage body) { + return attachmentAsync(name, type, body, AttachmentOptions.empty()); } /** - * Adds the attachment. + * Adds an async attachment and waits for its content before the owning executable ends. * - * @param name the display name or logical name to use - * @param type the event or label type - * @param content the attachment content - * @param fileExtension the attachment file extension + * @param name the name of attachment. + * @param type the content type of attachment. + * @param body the future stream that contains attachment content. + * @param options the attachment options. + * @return future completed when attachment content is written. */ - public static void addAttachment(final String name, final String type, - final InputStream content, final String fileExtension) { - addAttachmentAsStep(name, type, fileExtension, content); + public static CompletableFuture attachmentAsync( + final String name, final String type, + final CompletionStage body, + final AttachmentOptions options) { + return getLifecycle().addAttachmentStepAsync(name, type, body, options); } /** @@ -478,170 +514,19 @@ public static void addHttpExchange(final String name, final HttpExchange exchang addAttachmentAsStep( name, HttpExchange.CONTENT_TYPE, - HttpExchange.FILE_EXTENSION, - HttpExchangeSerializer.toJsonBytes(exchange) + HttpExchangeSerializer.toJsonBytes(exchange), + AttachmentOptions.empty() ); } - /** - * Adds the byte attachment async. - * - * @param name the display name or logical name to use - * @param type the event or label type - * @param body the attachment body - * @return this instance for method chaining - */ - public static CompletableFuture addByteAttachmentAsync( - final String name, final String type, final Supplier body) { - return addByteAttachmentAsync(name, type, "", body); - } - - /** - * Adds the byte attachment async. - * - * @param name the display name or logical name to use - * @param type the event or label type - * @param fileExtension the attachment file extension - * @param body the attachment body - * @return this instance for method chaining - */ - public static CompletableFuture addByteAttachmentAsync( - final String name, final String type, final String fileExtension, final Supplier body) { - final AllureLifecycle lifecycle = getLifecycle(); - final PreparedAttachment attachment = prepareAttachmentAsStep(lifecycle, name, type, fileExtension); - return supplyAsync(body).whenComplete((result, ex) -> { - if (Objects.nonNull(ex)) { - attachment.fail(ex); - return; - } - try { - lifecycle.writeAttachment(attachment.source(), new ByteArrayInputStream(result)); - } catch (Throwable throwable) { - attachment.fail(throwable); - throw ExceptionUtils.sneakyThrow(throwable); - } - }); - } - - /** - * Adds the stream attachment async. - * - * @param name the display name or logical name to use - * @param type the event or label type - * @param body the attachment body - * @return this instance for method chaining - */ - public static CompletableFuture addStreamAttachmentAsync( - final String name, final String type, final Supplier body) { - return addStreamAttachmentAsync(name, type, "", body); - } - - /** - * Adds the stream attachment async. - * - * @param name the display name or logical name to use - * @param type the event or label type - * @param fileExtension the attachment file extension - * @param body the attachment body - * @return this instance for method chaining - */ - public static CompletableFuture addStreamAttachmentAsync( - final String name, final String type, final String fileExtension, final Supplier body) { - final AllureLifecycle lifecycle = getLifecycle(); - final PreparedAttachment attachment = prepareAttachmentAsStep(lifecycle, name, type, fileExtension); - return supplyAsync(body).whenComplete((result, ex) -> { - if (Objects.nonNull(ex)) { - attachment.fail(ex); - return; - } - try { - lifecycle.writeAttachment(attachment.source(), result); - } catch (Throwable throwable) { - attachment.fail(throwable); - throw ExceptionUtils.sneakyThrow(throwable); - } - }); - } - private static void addAttachmentAsStep(final String name, final String type, - final String fileExtension, final byte[] body) { - addAttachmentAsStep(name, type, fileExtension, new ByteArrayInputStream(body)); + final byte[] body, final AttachmentOptions options) { + addAttachmentAsStep(name, type, new ByteArrayInputStream(body), options); } private static void addAttachmentAsStep(final String name, final String type, - final String fileExtension, final InputStream content) { - final AllureLifecycle lifecycle = getLifecycle(); - if (isDirectAttachmentWrite(lifecycle)) { - lifecycle.addAttachment(name, type, fileExtension, content); - return; - } - - final String uuid = UUID.randomUUID().toString(); - lifecycle.startStep(uuid, new StepResult().setName(attachmentStepName(name))); - try { - lifecycle.addAttachment(name, type, fileExtension, content); - lifecycle.updateStep(uuid, step -> step.setStatus(Status.PASSED)); - } catch (Throwable throwable) { - lifecycle.updateStep( - uuid, - step -> step - .setStatus(getStatus(throwable).orElse(Status.BROKEN)) - .setStatusDetails(getStatusDetails(throwable).orElse(null)) - ); - throw ExceptionUtils.sneakyThrow(throwable); - } finally { - lifecycle.stopStep(uuid); - } - } - - private static PreparedAttachment prepareAttachmentAsStep(final AllureLifecycle lifecycle, - final String name, - final String type, - final String fileExtension) { - if (isDirectAttachmentWrite(lifecycle)) { - return new PreparedAttachment( - lifecycle.prepareAttachment(name, type, fileExtension), - null - ); - } - - final String uuid = UUID.randomUUID().toString(); - final StepResult step = new StepResult() - .setName(attachmentStepName(name)) - .setStatus(Status.PASSED); - lifecycle.startStep(uuid, step); - try { - return new PreparedAttachment( - lifecycle.prepareAttachment(name, type, fileExtension), - step - ); - } catch (Throwable throwable) { - step.setStatus(getStatus(throwable).orElse(Status.BROKEN)) - .setStatusDetails(getStatusDetails(throwable).orElse(null)); - throw ExceptionUtils.sneakyThrow(throwable); - } finally { - lifecycle.stopStep(uuid); - } - } - - private static boolean isDirectAttachmentWrite(final AllureLifecycle lifecycle) { - return LifecycleNotifier.isListenerCallbackRunning() - || lifecycle.getCurrentTestCaseOrStep().isEmpty(); - } - - private static String attachmentStepName(final String name) { - return Objects.isNull(name) || name.isEmpty() ? "Attachment" : name; - } - - private record PreparedAttachment(String source, StepResult step) { - - void fail(final Throwable throwable) { - if (Objects.nonNull(step)) { - step.setStatus(getStatus(throwable).orElse(Status.BROKEN)) - .setStatusDetails(getStatusDetails(throwable).orElse(null)); - } - } - + final InputStream content, final AttachmentOptions options) { + getLifecycle().addAttachmentStep(name, type, content, options); } /** @@ -762,15 +647,15 @@ default T parameter(final String name, final T value, */ private static final class DefaultStepContext implements StepContext { - private final String uuid; + private final AllureExternalKey key; - private DefaultStepContext(final String uuid) { - this.uuid = uuid; + private DefaultStepContext(final AllureExternalKey key) { + this.key = key; } @Override public void name(final String name) { - getLifecycle().updateStep(uuid, stepResult -> stepResult.setName(name)); + getLifecycle().updateStep(key, stepResult -> stepResult.setName(name)); } @Override @@ -791,7 +676,7 @@ public T parameter(final String name, final T value, final Parameter.Mode mo @Override public T parameter(final String name, final T value, final Boolean excluded, final Parameter.Mode mode) { final Parameter param = createParameter(name, value, excluded, mode); - getLifecycle().updateStep(uuid, stepResult -> stepResult.getParameters().add(param)); + getLifecycle().updateStep(key, stepResult -> stepResult.getParameters().add(param)); return value; } } diff --git a/allure-java-commons/src/main/java/io/qameta/allure/AllureExternalKey.java b/allure-java-commons/src/main/java/io/qameta/allure/AllureExternalKey.java new file mode 100644 index 000000000..e51a75b53 --- /dev/null +++ b/allure-java-commons/src/main/java/io/qameta/allure/AllureExternalKey.java @@ -0,0 +1,144 @@ +/* + * 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; + +import io.qameta.allure.util.ResultsUtils; + +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.util.Arrays; +import java.util.Objects; +import java.util.UUID; + +/** + * Adapter-owned lifecycle identity. + * + *

A key is a recomputable identity built from framework data: reconstruct the same key in the start and + * stop hooks and equal keys resolve to the same internal Allure item, so neither the adapter nor the lifecycle needs a + * {@code Map}. The namespace is the integration class (provenance, and no ad-hoc constant strings); + * the values are the framework data that identifies the entity.

+ * + *

The key reduces its values to a digest at construction and retains no framework objects. Value contract: at + * least one value (enforced by the signature), no {@code null} values, no array values, and every value must have a + * stable, value-based serialized form ({@code String.valueOf}) for the lifetime of the run. The key must be unique + * per live entity: a retry of the same test must not recompute an equal key while the previous entity is still + * unwritten — include an attempt counter in the values when the framework can re-run the same id within one run.

+ * + *

Keys are process-local and in-memory only; they are never serialized into result files.

+ */ +public final class AllureExternalKey { + + private static final int DISPLAY_MAX_LENGTH = 160; + + private final byte[] digest; + + private final int hash; + + private final String display; + + private AllureExternalKey(final byte[] digest, final String display) { + this.digest = digest; + this.hash = Arrays.hashCode(digest); + this.display = display; + } + + /** + * Creates a key with the given namespace class and at least one identifying value. + * + * @param namespace the integration class that owns the key + * @param first the first identifying value (required) + * @param rest the remaining identifying values + * @return the key + */ + public static AllureExternalKey of(final Class namespace, final Object first, final Object... rest) { + Objects.requireNonNull(namespace, "namespace"); + final MessageDigest digest = ResultsUtils.getMd5Digest(); + final StringBuilder display = new StringBuilder(namespace.getSimpleName()).append('['); + update(digest, namespace.getName()); + appendValue(digest, display, first); + for (final Object value : rest) { + display.append(", "); + appendValue(digest, display, value); + } + display.append(']'); + return new AllureExternalKey(digest.digest(), truncate(display)); + } + + /** + * Creates a key with the given namespace class and a random UUIDv4 value, for frameworks that expose no stable id. + * + * @param namespace the integration class that owns the key + * @return the key + */ + public static AllureExternalKey random(final Class namespace) { + return of(namespace, UUID.randomUUID().toString()); + } + + @Override + public boolean equals(final Object o) { + if (this == o) { + return true; + } + if (!(o instanceof AllureExternalKey)) { + return false; + } + final AllureExternalKey that = (AllureExternalKey) o; + return hash == that.hash && Arrays.equals(digest, that.digest); + } + + @Override + public int hashCode() { + return hash; + } + + @Override + public String toString() { + return display; + } + + private static void appendValue(final MessageDigest digest, final StringBuilder display, final Object value) { + final String serialized = serialize(value); + update(digest, serialized); + display.append(serialized); + } + + private static String serialize(final Object value) { + Objects.requireNonNull(value, "key value must not be null"); + if (value.getClass().isArray()) { + throw new IllegalArgumentException( + "key value must not be an array (arrays have no stable value-based serialized form)" + ); + } + final String serialized = String.valueOf(value); + if (serialized.isEmpty()) { + throw new IllegalArgumentException("key value must not be empty"); + } + return serialized; + } + + private static void update(final MessageDigest digest, final String value) { + final byte[] bytes = value.getBytes(StandardCharsets.UTF_8); + digest.update(ByteBuffer.allocate(Integer.BYTES).putInt(bytes.length).array()); + digest.update(bytes); + } + + private static String truncate(final StringBuilder display) { + return display.length() <= DISPLAY_MAX_LENGTH + ? display.toString() + : display.substring(0, DISPLAY_MAX_LENGTH) + "…"; + } +} 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 a5cfd626e..0bdaf3dc6 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 @@ -15,9 +15,7 @@ */ package io.qameta.allure; -import io.qameta.allure.http.HttpExchange; -import io.qameta.allure.http.HttpExchangeSerializer; -import io.qameta.allure.internal.AllureStorage; +import io.qameta.allure.internal.AllureExecutionContext; import io.qameta.allure.internal.AllureThreadContext; import io.qameta.allure.listener.ContainerLifecycleListener; import io.qameta.allure.listener.FixtureLifecycleListener; @@ -26,28 +24,27 @@ import io.qameta.allure.listener.TestLifecycleListener; import io.qameta.allure.model.Attachment; import io.qameta.allure.model.FixtureResult; -import io.qameta.allure.model.Label; -import io.qameta.allure.model.Link; -import io.qameta.allure.model.Parameter; import io.qameta.allure.model.ScopeFixtureResult; import io.qameta.allure.model.ScopeFixtureType; import io.qameta.allure.model.ScopeResult; import io.qameta.allure.model.Stage; +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.model.WithAttachments; +import io.qameta.allure.model.WithMetadata; import io.qameta.allure.model.WithSteps; +import io.qameta.allure.util.ExceptionUtils; import io.qameta.allure.util.PropertiesUtils; +import io.qameta.allure.util.WellKnownFileExtensionsUtils; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import java.io.ByteArrayInputStream; import java.io.InputStream; import java.nio.file.Paths; import java.util.ArrayList; -import java.util.Deque; -import java.util.HashSet; +import java.util.Collection; import java.util.LinkedHashSet; import java.util.List; import java.util.Map; @@ -56,43 +53,70 @@ import java.util.Properties; import java.util.Set; import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; import java.util.concurrent.ConcurrentHashMap; -import java.util.concurrent.CopyOnWriteArraySet; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.function.BiConsumer; import java.util.function.Consumer; import static io.qameta.allure.AllureConstants.ATTACHMENT_FILE_SUFFIX; +import static io.qameta.allure.util.ResultsUtils.firstNonEmpty; +import static io.qameta.allure.util.ResultsUtils.getStatus; +import static io.qameta.allure.util.ResultsUtils.getStatusDetails; import static io.qameta.allure.util.ServiceLoaderUtils.load; /** - * The class contains Allure context and methods to change it. + * The Allure lifecycle: one class, three method groups. The addressing mode of every method is readable from its + * signature. * - *

Integration adapters should model suite-level grouping through {@link ScopeResult} by using - * {@link #startScope(ScopeResult)}, {@link #startScope(String, ScopeResult)}, {@link #updateScope(String, Consumer)}, - * {@link #stopScope(String)}, and {@link #writeScope(String)}. The test-container methods are retained only as - * migration bridges for existing adapters and are deprecated for removal.

+ *
    + *
  • Manual core — key-addressed methods. Every parent and owner is explicit; they touch no thread state + * and are safe from any thread. The exceptions are the start/stop transitions of tests and fixtures: starts bind + * the calling thread (the thread that calls start is by definition the executing thread), and stops unbind or + * restore only when the calling thread's root is the stopped key.
  • + *
  • Ambient group — keyless overloads that resolve their target from the calling thread's binding.
  • + *
  • Thread group — explicit binding control: {@link #setCurrent(AllureExternalKey)}, + * {@link #clearCurrent()}, {@link #bind(AllureExternalKey)}, {@link #bindDetached(AllureExternalKey)}, and the + * current-key accessors.
  • + *
+ * + *

Integration adapters model suite-level grouping through flat scopes using + * {@link #registerScope(AllureExternalKey)}, {@link #addTestToScope(AllureExternalKey, AllureExternalKey)}, and + * {@link #writeScope(AllureExternalKey)}.

*/ -@SuppressWarnings({"PMD.AvoidSynchronizedStatement", "PMD.GodClass", "PMD.TooManyMethods"}) +@SuppressWarnings({ + "PMD.AvoidSynchronizedStatement", + "PMD.GodClass", "PMD.TooManyMethods", "ClassFanOutComplexity"}) public class AllureLifecycle { private static final Logger LOGGER = LoggerFactory.getLogger(AllureLifecycle.class); - private final AllureResultsWriter writer; + private static final String EXTERNAL_KEY = "external key"; - private final AllureStorage storage; + private static final String KEY_NOT_FOUND = "Could not {}: item with key {} not found"; - private final AllureThreadContext threadContext; + private static final String WRONG_ENTITY = "Could not {}: item with key {} is not the expected type"; - private final LifecycleNotifier notifier; + private static final String KEY_ALREADY_EXISTS = "Could not {}: item with key {} already exists"; - private final Map fixtureContexts = new ConcurrentHashMap<>(); + private static final String NO_CONTEXT_FOR_ATTACHMENT = "Could not add attachment: no test or fixture running"; - private final Map scopes = new ConcurrentHashMap<>(); + private static final String ADD_TEST_TO_SCOPE = "add test to scope"; - private final Map scopeMetadata = new ConcurrentHashMap<>(); + private static final String SCHEDULE_TEST = "schedule test"; - private final Map scopeParents = new ConcurrentHashMap<>(); + private static final String START_FIXTURE = "start fixture"; - private final Map> testScopes = new ConcurrentHashMap<>(); + private static final String START_STEP = "start step"; + + private final AllureResultsWriter writer; + + private final AllureThreadContext threadContext; + + private final LifecycleNotifier notifier; + + private final Map items = new ConcurrentHashMap<>(); /** * Creates a new lifecycle with default results writer. Shortcut @@ -120,957 +144,1137 @@ public AllureLifecycle(final AllureResultsWriter writer) { AllureLifecycle(final AllureResultsWriter writer, final LifecycleNotifier lifecycleNotifier) { this.notifier = lifecycleNotifier; this.writer = writer; - this.storage = new AllureStorage(); this.threadContext = new AllureThreadContext(); } - /** - * Starts scope with specified parent scope. - * - * @param parentScopeUuid the uuid of parent scope. - * @param scope the scope. - */ - public void startScope(final String parentScopeUuid, final ScopeResult scope) { - scopeParents.put(scope.getUuid(), parentScopeUuid); - final ScopeResult parentScope = scopes.get(parentScopeUuid); - if (Objects.nonNull(parentScope)) { - synchronized (parentScope) { - normalizeScope(parentScope); - parentScope.getChildScopes().add(scope.getUuid()); - } - } else { - storage.getContainer(parentScopeUuid).ifPresent(parent -> { - synchronized (storage) { - parent.getChildren().add(scope.getUuid()); - } - }); - } - startScope(scope); - } + // ── Scopes ─────────────────────────────────────────────────────────────────────────────── /** - * Starts scope. + * Registers scope. * - * @param scope the scope. + * @param key the external scope key */ - public void startScope(final ScopeResult scope) { - normalizeScope(scope); - scopes.put(scope.getUuid(), scope); - linkExistingTests(scope); + public void registerScope(final AllureExternalKey key) { + Objects.requireNonNull(key, EXTERNAL_KEY); + final ScopeResult scope = new ScopeResult() + .setUuid(UUID.randomUUID().toString()); + if (Objects.nonNull(items.putIfAbsent(key, new ScopeItem(scope)))) { + LOGGER.warn(KEY_ALREADY_EXISTS, "register scope", key); + } } /** - * Starts test container with specified parent container. - * - * @deprecated use {@link #startScope(String, ScopeResult)} instead. This method is a migration bridge for - * existing adapters and is planned for removal after the 3.x transition. + * Adds test to scope. The test is referenced by its runtime key, so it must still be live in storage; the + * scope's metadata is merged into it when it stops. * - * @param containerUuid the uuid of parent container. - * @param container the container. + * @param scopeKey the external scope key + * @param testKey the external test key */ - @Deprecated( - since = "3.0.0", - forRemoval = true - ) - public void startTestContainer(final String containerUuid, final TestResultContainer container) { - scopeParents.put(container.getUuid(), containerUuid); - storage.getContainer(containerUuid).ifPresent(parent -> { - synchronized (storage) { - parent.getChildren().add(container.getUuid()); - } - }); - startContainer(container); + public void addTestToScope(final AllureExternalKey scopeKey, final AllureExternalKey testKey) { + final ScopeItem scope = getItem(scopeKey, ScopeItem.class, ADD_TEST_TO_SCOPE); + final TestItem test = getItem(testKey, TestItem.class, ADD_TEST_TO_SCOPE); + if (Objects.isNull(scope) || Objects.isNull(test)) { + return; + } + test.scopes().add(scopeKey); + addTest(scope, test.result().getUuid()); } /** - * Starts test container. - * - * @deprecated use {@link #startScope(ScopeResult)} instead. This method is a migration bridge for existing - * adapters and is planned for removal after the 3.x transition. + * Adds test to scope, referencing the test by its model uuid instead of a runtime key. Use for tests that are + * already written and released from storage — the normal case for a scope that is written after its children, + * such as an after-method scope or a suite scope closing at run end. * - * @param container the container. + * @param scopeKey the external scope key + * @param testUuid the model uuid of the test */ - @Deprecated( - since = "3.0.0", - forRemoval = true - ) - public void startTestContainer(final TestResultContainer container) { - startContainer(container); - } - - private void startContainer(final TestResultContainer container) { - notifier.beforeContainerStart(container); - container.setStart(System.currentTimeMillis()); - storage.put(container.getUuid(), container); - scopeMetadata.computeIfAbsent(container.getUuid(), key -> new ScopeMetadata()); - linkExistingChildren(container); - notifier.afterContainerStart(container); + public void addTestToScope(final AllureExternalKey scopeKey, final String testUuid) { + final ScopeItem scope = getItem(scopeKey, ScopeItem.class, ADD_TEST_TO_SCOPE); + if (Objects.isNull(scope)) { + return; + } + addTest(scope, testUuid); } /** - * Updates scope. + * Writes scope. * - * @param uuid the uuid of scope. - * @param update the update function. + * @param key the external scope key */ - public void updateScope(final String uuid, final Consumer update) { - final ScopeResult scope = scopes.get(uuid); + public void writeScope(final AllureExternalKey key) { + final ScopeItem scope = getItem(key, ScopeItem.class, "write scope"); if (Objects.isNull(scope)) { - LOGGER.error("Could not update scope: scope with uuid {} not found", uuid); return; } + final TestResultContainer container; synchronized (scope) { - update.accept(scope); - normalizeScope(scope); - linkExistingTests(scope); + container = toScopeContainer(scope.result()); } + // the scope may be written before its linked tests stop (for example TestNG per-method scopes are written + // at test start) — drain its metadata into the still-live tests now, claiming the link so the merge at + // stopTest cannot apply it twice + items.values().forEach(item -> { + if (item instanceof TestItem && ((TestItem) item).scopes().remove(key)) { + synchronized (scope) { + mergeScopeMetadata(scope.result(), ((TestItem) item).result()); + } + } + }); + notifier.beforeContainerWrite(container); + waitForFutures(scope.futures()); + writer.write(container); + sweepOwnedSteps(key); + items.remove(key); + notifier.afterContainerWrite(container); } + // ── Tests ──────────────────────────────────────────────────────────────────────────────── + /** - * Updates test container. - * - * @deprecated use {@link #updateScope(String, Consumer)} instead. This method is a migration bridge for - * existing adapters and is planned for removal after the 3.x transition. + * Schedules test with given key. * - * @param uuid the uuid of container. - * @param update the update function. + * @param key the external test key + * @param result the test to schedule */ - @Deprecated( - since = "3.0.0", - forRemoval = true - ) - public void updateTestContainer(final String uuid, final Consumer update) { - final Optional found = storage.getContainer(uuid); - if (!found.isPresent()) { - LOGGER.error("Could not update test container: container with uuid {} not found", uuid); + public void scheduleTest(final AllureExternalKey key, final TestResult result) { + Objects.requireNonNull(key, EXTERNAL_KEY); + if (items.containsKey(key)) { + LOGGER.warn(KEY_ALREADY_EXISTS, SCHEDULE_TEST, key); return; } - final TestResultContainer container = found.get(); - notifier.beforeContainerUpdate(container); - update.accept(container); - linkExistingChildren(container); - notifier.afterContainerUpdate(container); - } - - /** - * Stops scope by given uuid. - * - * @param uuid the uuid of scope. - */ - public void stopScope(final String uuid) { - if (!scopes.containsKey(uuid)) { - LOGGER.error("Could not stop scope: scope with uuid {} not found", uuid); + if (firstNonEmpty(result.getUuid()).isEmpty()) { + result.setUuid(UUID.randomUUID().toString()); + } + notifier.beforeTestSchedule(result); + result.setStage(Stage.SCHEDULED); + if (Objects.nonNull(items.putIfAbsent(key, new TestItem(result)))) { + LOGGER.warn(KEY_ALREADY_EXISTS, SCHEDULE_TEST, key); + return; } + notifier.afterTestSchedule(result); } /** - * Stops test container by given uuid. + * Schedules test with given scopes. * - * @deprecated use {@link #stopScope(String)} instead. This method is a migration bridge for existing adapters - * and is planned for removal after the 3.x transition. - * - * @param uuid the uuid of container. + * @param scopeKeys the external scope keys + * @param key the external test key + * @param result the test to schedule */ - @Deprecated( - since = "3.0.0", - forRemoval = true - ) - public void stopTestContainer(final String uuid) { - stopContainer(uuid); + public void scheduleTest(final Collection scopeKeys, + final AllureExternalKey key, + final TestResult result) { + scheduleTest(key, result); + scopeKeys.forEach(scopeKey -> addTestToScope(scopeKey, key)); } /** - * Writes scope with given uuid. + * Starts test with given key and binds it as the calling thread's root. The test must be scheduled. * - * @param uuid the uuid of scope. + * @param key the external test key */ - public void writeScope(final String uuid) { - final ScopeResult scope = scopes.get(uuid); - if (Objects.isNull(scope)) { - LOGGER.error("Could not write scope: scope with uuid {} not found", uuid); + public void startTest(final AllureExternalKey key) { + final TestItem item = getItem(key, TestItem.class, "start test"); + if (Objects.isNull(item)) { return; } - - final TestResultContainer container; - synchronized (scope) { - normalizeScope(scope); - container = toScopeContainer(scope); + final TestResult testResult = item.result(); + if (!Stage.SCHEDULED.equals(testResult.getStage())) { + LOGGER.warn("Could not start test: test with key {} is not scheduled", key); + return; } - writeContainer(container); - - scopes.remove(uuid); - scopeParents.remove(uuid); + threadContext.clear(); + notifier.beforeTestStart(testResult); + testResult + .setStage(Stage.RUNNING) + .setStart(System.currentTimeMillis()); + threadContext.start(key); + notifier.afterTestStart(testResult); } /** - * Writes test container with given uuid. - * - * @deprecated use {@link #writeScope(String)} instead. This method is a migration bridge for existing adapters - * and is planned for removal after the 3.x transition. + * Updates test by given key. * - * @param uuid the uuid of container. + * @param key the external test key + * @param update the update function */ - @Deprecated( - since = "3.0.0", - forRemoval = true - ) - public void writeTestContainer(final String uuid) { - writeContainer(uuid); + public void updateTest(final AllureExternalKey key, final Consumer update) { + final TestItem item = getItem(key, TestItem.class, "update test"); + if (Objects.isNull(item)) { + return; + } + notifier.beforeTestUpdate(item.result()); + update.accept(item.result()); + notifier.afterTestUpdate(item.result()); } /** - * Start a new before fixture with given scope. + * Updates current running test. * - * @param scopeUuid the uuid of owning scope. - * @param uuid the fixture uuid. - * @param result the fixture. + * @param update the update function. */ - public void startBeforeFixture(final String scopeUuid, final String uuid, final FixtureResult result) { - addFixtureToScopeOrContainer(scopeUuid, uuid, result, ScopeFixtureType.BEFORE); - notifier.beforeFixtureStart(result); - startFixture(uuid, result, new FixtureContext(scopeUuid, ScopeFixtureType.BEFORE, threadContext.copy())); - notifier.afterFixtureStart(result); + public void updateTest(final Consumer update) { + final Optional root = threadContext.getRoot(); + if (root.isEmpty()) { + LOGGER.warn("Could not update test: no test running"); + return; + } + updateTest(root.get(), update); } /** - * Start a new prepare fixture with given parent. + * Stops test by given key. The test must be running; scope metadata is merged into the test here. Unbinds the + * calling thread only if the test is the calling thread's root. * - * @deprecated use {@link #startBeforeFixture(String, String, FixtureResult)} instead. This method is a - * migration bridge for existing adapters and is planned for removal after the 3.x transition. - * - * @param containerUuid the uuid of parent container. - * @param uuid the fixture uuid. - * @param result the fixture. + * @param key the external test key */ - @Deprecated( - since = "3.0.0", - forRemoval = true - ) - public void startPrepareFixture(final String containerUuid, final String uuid, final FixtureResult result) { - startBeforeFixture(containerUuid, uuid, result); + public void stopTest(final AllureExternalKey key) { + final TestItem item = getItem(key, TestItem.class, "stop test"); + if (Objects.isNull(item)) { + return; + } + final TestResult testResult = item.result(); + if (!Stage.RUNNING.equals(testResult.getStage())) { + LOGGER.warn("Could not stop test: test with key {} is not running", key); + return; + } + if (isCurrentRoot(key)) { + closeOpenStages(); + } + notifier.beforeTestStop(testResult); + testResult + .setStage(Stage.FINISHED) + .setStop(System.currentTimeMillis()); + applyScopeMetadata(item); + if (isCurrentRoot(key)) { + threadContext.clear(); + } + notifier.afterTestStop(testResult); } /** - * Start a new after fixture with given scope. + * Writes test by given key. Waits for the test's pending async attachments before serializing, so the written + * result file is a completion marker: everything it references exists. * - * @param scopeUuid the uuid of owning scope. - * @param uuid the fixture uuid. - * @param result the fixture. + * @param key the external test key */ - public void startAfterFixture(final String scopeUuid, final String uuid, final FixtureResult result) { - addFixtureToScopeOrContainer(scopeUuid, uuid, result, ScopeFixtureType.AFTER); - notifier.beforeFixtureStart(result); - startFixture(uuid, result, new FixtureContext(scopeUuid, ScopeFixtureType.AFTER, threadContext.copy())); - notifier.afterFixtureStart(result); + public void writeTest(final AllureExternalKey key) { + final TestItem item = getItem(key, TestItem.class, "write test"); + if (Objects.isNull(item)) { + return; + } + notifier.beforeTestWrite(item.result()); + waitForFutures(item.futures()); + writer.write(item.result()); + sweepOwnedSteps(key); + items.remove(key); + notifier.afterTestWrite(item.result()); } + // ── Fixtures ───────────────────────────────────────────────────────────────────────────── + /** - * Start a new tear down fixture with given parent. - * - * @deprecated use {@link #startAfterFixture(String, String, FixtureResult)} instead. This method is a - * migration bridge for existing adapters and is planned for removal after the 3.x transition. + * Starts a new before fixture with given scope and binds it as the calling thread's root, saving the previous + * binding for {@link #stopFixture(AllureExternalKey)} to restore. * - * @param containerUuid the uuid of parent container. - * @param uuid the fixture uuid. - * @param result the fixture. + * @param scopeKey the external scope key + * @param fixtureKey the external fixture key + * @param result the fixture */ - @Deprecated( - since = "3.0.0", - forRemoval = true - ) - public void startTearDownFixture(final String containerUuid, final String uuid, final FixtureResult result) { - startAfterFixture(containerUuid, uuid, result); + public void startBeforeFixture(final AllureExternalKey scopeKey, final AllureExternalKey fixtureKey, + final FixtureResult result) { + startFixture(scopeKey, fixtureKey, result, ScopeFixtureType.BEFORE); } /** - * Start a new fixture with given uuid. + * Starts a new after fixture with given scope and binds it as the calling thread's root, saving the previous + * binding for {@link #stopFixture(AllureExternalKey)} to restore. * - * @param uuid the uuid of fixture. - * @param result the test fixture. + * @param scopeKey the external scope key + * @param fixtureKey the external fixture key + * @param result the fixture */ - private void startFixture(final String uuid, final FixtureResult result, final FixtureContext context) { - fixtureContexts.put(uuid, context); - storage.put(uuid, result); + public void startAfterFixture(final AllureExternalKey scopeKey, final AllureExternalKey fixtureKey, + final FixtureResult result) { + startFixture(scopeKey, fixtureKey, result, ScopeFixtureType.AFTER); + } + + private void startFixture(final AllureExternalKey scopeKey, final AllureExternalKey key, + final FixtureResult result, final ScopeFixtureType type) { + Objects.requireNonNull(key, EXTERNAL_KEY); + final ScopeItem scope = getItem(scopeKey, ScopeItem.class, START_FIXTURE); + if (Objects.isNull(scope)) { + return; + } + if (items.containsKey(key)) { + LOGGER.warn(KEY_ALREADY_EXISTS, START_FIXTURE, key); + return; + } + synchronized (scope) { + scope.result().getFixtures().add( + new ScopeFixtureResult() + .setUuid(UUID.randomUUID().toString()) + .setValue(result) + .setType(type) + .setScopeUuid(scope.result().getUuid()) + ); + } + notifier.beforeFixtureStart(result); + final FixtureItem item = new FixtureItem(result, scopeKey, type, threadContext.copy()); + items.put(key, item); result.setStage(Stage.RUNNING); result.setStart(System.currentTimeMillis()); threadContext.clear(); - threadContext.start(uuid); + threadContext.start(key); + notifier.afterFixtureStart(result); } /** - * Updates current running fixture. Shortcut for {@link #updateFixture(String, Consumer)}. + * Updates fixture by given key. * - * @param update the update function. + * @param key the external fixture key + * @param update the update function */ - public void updateFixture(final Consumer update) { - final Optional root = threadContext.getRoot(); - if (!root.isPresent()) { - LOGGER.error("Could not update test fixture: no test fixture running"); + public void updateFixture(final AllureExternalKey key, final Consumer update) { + final FixtureItem item = getItem(key, FixtureItem.class, "update fixture"); + if (Objects.isNull(item)) { return; } - final String uuid = root.get(); - updateFixture(uuid, update); + notifier.beforeFixtureUpdate(item.result()); + update.accept(item.result()); + notifier.afterFixtureUpdate(item.result()); } /** - * Updates fixture by given uuid. + * Updates current running fixture. * - * @param uuid the uuid of fixture. * @param update the update function. */ - public void updateFixture(final String uuid, final Consumer update) { - final Optional found = storage.getFixture(uuid); - if (!found.isPresent()) { - LOGGER.error("Could not update test fixture: test fixture with uuid {} not found", uuid); + public void updateFixture(final Consumer update) { + final Optional root = threadContext.getRoot(); + if (root.isEmpty()) { + LOGGER.warn("Could not update fixture: no fixture running"); return; } - final FixtureResult fixture = found.get(); - - notifier.beforeFixtureUpdate(fixture); - update.accept(fixture); - notifier.afterFixtureUpdate(fixture); + updateFixture(root.get(), update); } /** - * Stops fixture by given uuid. + * Stops fixture by given key. Restores the binding saved at fixture start only if the fixture is the calling + * thread's root. * - * @param uuid the uuid of fixture. + * @param key the external fixture key */ - public void stopFixture(final String uuid) { - final Optional found = storage.getFixture(uuid); - if (!found.isPresent()) { - LOGGER.error("Could not stop test fixture: test fixture with uuid {} not found", uuid); + public void stopFixture(final AllureExternalKey key) { + final FixtureItem item = getItem(key, FixtureItem.class, "stop fixture"); + if (Objects.isNull(item)) { return; } - final FixtureResult fixture = found.get(); - + final FixtureResult fixture = item.result(); + if (isCurrentRoot(key)) { + closeOpenStages(); + } notifier.beforeFixtureStop(fixture); fixture.setStage(Stage.FINISHED); fixture.setStop(System.currentTimeMillis()); - - storage.remove(uuid); - restoreContextAfterFixture(uuid); - + if (isCurrentRoot(key)) { + threadContext.set(item.savedContext()); + } + items.remove(key); notifier.afterFixtureStop(fixture); } + // ── Steps ──────────────────────────────────────────────────────────────────────────────── + /** - * Returns uuid of current running test case if any. + * Starts a new step as a child of the current executable and makes it current on the calling thread. Takes no + * effect if no executable is running. * - * @return the uuid of current running test case. + * @param result the step */ - public Optional getCurrentTestCase() { - return threadContext.getRoot() - .filter(uuid -> storage.getTestResult(uuid).isPresent()); + public void startStep(final StepResult result) { + startStep(AllureExternalKey.random(AllureLifecycle.class), result); } /** - * Returns uuid of current running test case or step if any. + * Starts a new step as a child of the current executable and makes it current on the calling thread, using the + * given key as the step's identity. The key lets callers address this step later (for example a + * {@code StepContext} that must target this step even while a nested step is current). Takes no effect if no + * executable is running. * - * @return the uuid of current running test case or step. + * @param key the external step key + * @param result the step */ - public Optional getCurrentTestCaseOrStep() { - return threadContext.getCurrent(); + public void startStep(final AllureExternalKey key, final StepResult result) { + final Optional current = threadContext.getCurrentExecutable(); + if (current.isEmpty()) { + LOGGER.warn("Could not start step: no test or fixture running"); + return; + } + startStep(current.get(), key, result, true); } /** - * Sets specified test case uuid as current. Note that - * test case with such uuid should be created and existed in storage, otherwise - * method take no effect. + * Starts a new step as a child of the specified parent. Pure manual linkage: the step is attached under the + * parent and no thread state is touched, so this is safe to call from any thread. Stop it with + * {@link #stopStep(AllureExternalKey)}. * - * @param uuid the uuid of test case. - * @return true if current test case was configured successfully, false otherwise. + * @param parentKey the external parent key + * @param key the external step key + * @param result the step */ - public boolean setCurrentTestCase(final String uuid) { - final Optional found = storage.getTestResult(uuid); - if (!found.isPresent()) { - return false; + public void startStep(final AllureExternalKey parentKey, final AllureExternalKey key, final StepResult result) { + startStep(parentKey, key, result, false); + } + + private void startStep(final AllureExternalKey parentKey, final AllureExternalKey key, + final StepResult result, final boolean bind) { + startStep(parentKey, key, result, bind, false); + } + + private void startStep(final AllureExternalKey parentKey, final AllureExternalKey key, + final StepResult result, final boolean bind, final boolean stage) { + Objects.requireNonNull(key, EXTERNAL_KEY); + Objects.requireNonNull(parentKey, EXTERNAL_KEY); + final Object parent = items.get(parentKey); + if (Objects.isNull(parent)) { + LOGGER.warn(KEY_NOT_FOUND, START_STEP, parentKey); + return; } - threadContext.clear(); - threadContext.start(uuid); - return true; + final Object parentModel = modelOf(parent); + if (!(parentModel instanceof WithSteps)) { + LOGGER.warn(WRONG_ENTITY, START_STEP, parentKey); + return; + } + if (items.containsKey(key)) { + LOGGER.warn(KEY_ALREADY_EXISTS, START_STEP, key); + return; + } + + notifier.beforeStepStart(result); + result.setStage(Stage.RUNNING); + result.setStart(System.currentTimeMillis()); + + if (bind) { + threadContext.start(key); + } + final AllureExecutionContext snapshot = bind + ? threadContext.copy() + : deriveSnapshot(parentKey, parent, key); + items.put(key, new StepItem(result, writeOwnerOf(parentKey, parent), snapshot, stage)); + synchronized (parent) { + ((WithSteps) parentModel).getSteps().add(result); + } + notifier.afterStepStart(result); } /** - * Schedules test case with given scope. + * Starts a stage — a lightweight phase marker rendered as a regular step. A stage has no explicit stop: it stays + * open, collecting the steps and attachments that follow, until the next stage starts at the same level or the + * enclosing step, test, or fixture ends. A stage started inside a step becomes a child of that step. A stage + * with no status when it closes is marked passed. * - * @param containerUuid the uuid of scope. - * @param result the test case to schedule. + *

Stages are an ambient-only concept: their lifetime is defined by the calling thread's binding, so there is + * no key-addressed form. Takes no effect if no executable is running.

+ * + * @param result the stage step, carrying its name */ - public void scheduleTestCase(final String containerUuid, final TestResult result) { - final ScopeResult scope = scopes.get(containerUuid); - if (Objects.nonNull(scope)) { - addTestChildToScope(containerUuid, result.getUuid()); - } else { - storage.getContainer(containerUuid).ifPresent(container -> { - synchronized (storage) { - container.getChildren().add(result.getUuid()); - } - }); + public void startStage(final StepResult result) { + if (threadContext.getCurrentExecutable().isEmpty()) { + LOGGER.warn("Could not start stage: no test or fixture running"); + return; + } + closeOpenStages(); + final Optional parent = threadContext.getCurrentExecutable(); + if (parent.isEmpty()) { + return; } - linkTestToScope(containerUuid, result.getUuid()); - scheduleTestCase(result); + startStep(parent.get(), AllureExternalKey.random(AllureLifecycle.class), result, true, true); } /** - * Schedule given test case. - * - * @param result the test case to schedule. + * Closes consecutive open stages on top of the calling thread's stack. A stage with no status is marked passed. */ - public void scheduleTestCase(final TestResult result) { - notifier.beforeTestSchedule(result); - result.setStage(Stage.SCHEDULED); - storage.put(result.getUuid(), result); - notifier.afterTestSchedule(result); + private void closeOpenStages() { + closeOpenStagesAbove(null); } /** - * Starts test case with given uuid. In order to start test case it should be scheduled at first. - * - * @param uuid the uuid of test case to start. + * Closes consecutive open stages bound above the given step on the calling thread's stack, or all consecutive + * top stages when the step is {@code null}. A stage with no status is marked passed. */ - public void startTestCase(final String uuid) { - threadContext.clear(); - final Optional found = storage.getTestResult(uuid); - if (!found.isPresent()) { - LOGGER.error("Could not start test case: test case with uuid {} is not scheduled", uuid); - return; + private void closeOpenStagesAbove(final AllureExternalKey key) { + while (true) { + final Optional current = threadContext.getCurrentStep(); + if (current.isEmpty() || current.get().equals(key)) { + return; + } + final Object item = items.get(current.get()); + if (!(item instanceof StepItem) || !((StepItem) item).stage()) { + return; + } + if (Objects.isNull(((StepItem) item).result().getStatus())) { + ((StepItem) item).result().setStatus(Status.PASSED); + } + stopStep(current.get(), true); } - final TestResult testResult = found.get(); - - notifier.beforeTestStart(testResult); - testResult - .setStage(Stage.RUNNING) - .setStart(System.currentTimeMillis()); - threadContext.start(uuid); - notifier.afterTestStart(testResult); } /** - * Shortcut for {@link #updateTestCase(String, Consumer)} for current running test case uuid. + * Updates step by specified key. * - * @param update the update function. + * @param key the external step key + * @param update the update function */ - public void updateTestCase(final Consumer update) { - final Optional root = threadContext.getRoot(); - if (!root.isPresent()) { - LOGGER.error("Could not update test case: no test case running"); + public void updateStep(final AllureExternalKey key, final Consumer update) { + final StepItem item = getItem(key, StepItem.class, "update step"); + if (Objects.isNull(item)) { return; } - - final String uuid = root.get(); - updateTestCase(uuid, update); + notifier.beforeStepUpdate(item.result()); + update.accept(item.result()); + notifier.afterStepUpdate(item.result()); } /** - * Updates test case by given uuid. + * Updates the current running step. A stage cannot be updated: stages are addressed by nobody once started, so + * when the current step is a stage this warns and does nothing — a caller finishing its own step must address + * it by key. * - * @param uuid the uuid of test case to update. * @param update the update function. */ - public void updateTestCase(final String uuid, final Consumer update) { - final Optional found = storage.getTestResult(uuid); - if (!found.isPresent()) { - LOGGER.error("Could not update test case: test case with uuid {} not found", uuid); + public void updateStep(final Consumer update) { + final Optional current = threadContext.getCurrentStep(); + if (current.isEmpty()) { + LOGGER.warn("Could not update step: no step running"); return; } - final TestResult testResult = found.get(); - - notifier.beforeTestUpdate(testResult); - update.accept(testResult); - notifier.afterTestUpdate(testResult); + final Object item = items.get(current.get()); + if (item instanceof StepItem && ((StepItem) item).stage()) { + LOGGER.warn("Could not update step: the current step is a stage"); + return; + } + updateStep(current.get(), update); } /** - * Stops test case by given uuid. Test case marked as {@link Stage#FINISHED} and also - * stop timestamp is calculated. Result would be stored in memory until - * {@link #writeTestCase(String)} method is called. Also stopped test case could be - * updated by {@link #updateTestCase(String, Consumer)} method. + * Stops step by given key. Pure manual form with one thread-affine convenience: when the stopped step is bound + * on the calling thread with open stages above it, those stages are closed first. * - * @param uuid the uuid of test case to stop. + * @param key the external step key */ - public void stopTestCase(final String uuid) { - final Optional found = storage.getTestResult(uuid); - if (!found.isPresent()) { - LOGGER.error("Could not stop test case: test case with uuid {} not found", uuid); - return; + public void stopStep(final AllureExternalKey key) { + if (threadContext.getLocalKeys().contains(key)) { + closeOpenStagesAbove(key); } - final TestResult testResult = found.get(); - - notifier.beforeTestStop(testResult); - testResult - .setStage(Stage.FINISHED) - .setStop(System.currentTimeMillis()); - applyScopeMetadata(testResult); - threadContext.clear(); - notifier.afterTestStop(testResult); + stopStep(key, false); } /** - * Writes test case with given uuid using configured {@link AllureResultsWriter}. - * - * @param uuid the uuid of test case to write. + * Stops the current running step and pops it from the calling thread. Open stages above it are closed first. */ - public void writeTestCase(final String uuid) { - final Optional found = storage.getTestResult(uuid); - if (!found.isPresent()) { - LOGGER.error("Could not write test case: test case with uuid {} not found", uuid); + public void stopStep() { + closeOpenStages(); + final Optional current = threadContext.getCurrentStep(); + if (current.isEmpty()) { + LOGGER.warn("Could not stop step: no step running"); return; } + stopStep(current.get(), true); + } - final TestResult testResult = found.get(); - notifier.beforeTestWrite(testResult); - writer.write(testResult); - storage.remove(uuid); - testScopes.remove(uuid); - notifier.afterTestWrite(testResult); + private void stopStep(final AllureExternalKey key, final boolean unbind) { + final StepItem item = getItem(key, StepItem.class, "stop step"); + if (Objects.isNull(item)) { + return; + } + final StepResult step = item.result(); + notifier.beforeStepStop(step); + step.setStage(Stage.FINISHED); + step.setStop(System.currentTimeMillis()); + items.remove(key); + if (unbind) { + threadContext.stop(); + } + notifier.afterStepStop(step); } /** - * Adds metadata label to the current test or current before-fixture scope. + * Logs an instant step — started and finished in one call — under the current executable. The step is bound as + * current for the duration of its listener callbacks, so listeners observe it exactly like a regular step. + * Takes no effect if no executable is running. * - * @param label the label + * @param result the step, carrying its name and status */ - public void addLabel(final Label label) { - updateTestOrBeforeFixtureScope( - "label", - testResult -> testResult.getLabels().add(label), - scope -> scope.getLabels().add(label), - metadata -> metadata.getLabels().add(label) - ); + public void logStep(final StepResult result) { + final Optional current = threadContext.getCurrentExecutable(); + if (current.isEmpty()) { + LOGGER.warn("Could not log step: no test or fixture running"); + return; + } + final AllureExternalKey key = AllureExternalKey.random(AllureLifecycle.class); + startStep(current.get(), key, result, true); + if (items.containsKey(key)) { + stopStep(key, true); + } } /** - * Adds metadata link to the current test or current before-fixture scope. + * Logs an instant step — started and finished in one call — under the specified parent. Pure manual linkage: + * no thread state is touched, so this is safe to call from any thread. * - * @param link the link + * @param parentKey the external parent key + * @param result the step, carrying its name and status */ - public void addLink(final Link link) { - updateTestOrBeforeFixtureScope( - "link", - testResult -> testResult.getLinks().add(link), - scope -> scope.getLinks().add(link), - metadata -> metadata.getLinks().add(link) - ); + public void logStep(final AllureExternalKey parentKey, final StepResult result) { + final AllureExternalKey key = AllureExternalKey.random(AllureLifecycle.class); + startStep(parentKey, key, result, false); + if (items.containsKey(key)) { + stopStep(key, false); + } } + // ── Attachments ────────────────────────────────────────────────────────────────────────── + /** - * Adds metadata parameter to the current test or current before-fixture scope. + * Adds attachment to a running test, fixture, or step by key. * - * @param parameter the parameter + * @param key the external executable key + * @param name the name of attachment + * @param type the content type of attachment + * @param stream attachment content + * @param options the attachment options */ - public void addParameter(final Parameter parameter) { - updateTestOrBeforeFixtureScope( - "parameter", - testResult -> testResult.getParameters().add(parameter), - scope -> scope.getParameters().add(parameter), - metadata -> metadata.getParameters().add(parameter) - ); + public void addAttachment(final AllureExternalKey key, final String name, final String type, + final InputStream stream, final AttachmentOptions options) { + addAttachmentLink(key, name, type, options) + .ifPresent(source -> writer.write(source, stream)); } /** - * Sets description on the current test or current before-fixture scope. + * Adds attachment to the current test, fixture, or step if one is running. * - * @param description the description + * @param name the name of attachment + * @param type the content type of attachment + * @param stream attachment content + * @param options the attachment options */ - public void setDescription(final String description) { - updateTestOrBeforeFixtureScope( - "description", - testResult -> testResult.setDescription(description), - scope -> scope.setDescription(description), - metadata -> metadata.setDescription(description) - ); + public void addAttachment(final String name, final String type, + final InputStream stream, final AttachmentOptions options) { + final Optional current = threadContext.getCurrentExecutable(); + if (current.isEmpty()) { + LOGGER.warn(NO_CONTEXT_FOR_ATTACHMENT); + return; + } + addAttachment(current.get(), name, type, stream, options); } /** - * Sets HTML description on the current test or current before-fixture scope. + * Adds an async attachment to a running test, fixture, or step by key. The attachment content is awaited before + * the owning test or scope is written. * - * @param descriptionHtml the HTML description + * @param key the external executable key + * @param name the name of attachment + * @param type the content type of attachment + * @param body the future stream that contains attachment content + * @param options the attachment options + * @return future completed when attachment content is written */ - public void setDescriptionHtml(final String descriptionHtml) { - updateTestOrBeforeFixtureScope( - "descriptionHtml", - testResult -> testResult.setDescriptionHtml(descriptionHtml), - scope -> scope.setDescriptionHtml(descriptionHtml), - metadata -> metadata.setDescriptionHtml(descriptionHtml) - ); + public CompletableFuture addAttachmentAsync(final AllureExternalKey key, final String name, + final String type, + final CompletionStage body, + final AttachmentOptions options) { + return addAttachmentAsync(key, name, type, body, options, null); } /** - * Start a new step as child step of current running test case or step. Shortcut - * for {@link #startStep(String, String, StepResult)}. + * Adds an async attachment to the current test, fixture, or step if one is running. The attachment content is + * awaited before the owning test or scope is written. * - * @param uuid the uuid of step. - * @param result the step. + * @param name the name of attachment + * @param type the content type of attachment + * @param body the future stream that contains attachment content + * @param options the attachment options + * @return future completed when attachment content is written */ - public void startStep(final String uuid, final StepResult result) { - final Optional current = threadContext.getCurrent(); - if (!current.isPresent()) { - LOGGER.error("Could not start step: no test case running"); - return; + public CompletableFuture addAttachmentAsync(final String name, final String type, + final CompletionStage body, + final AttachmentOptions options) { + final Optional current = threadContext.getCurrentExecutable(); + if (current.isEmpty()) { + LOGGER.warn(NO_CONTEXT_FOR_ATTACHMENT); + return CompletableFuture.completedFuture(null); } - final String parentUuid = current.get(); - startStep(parentUuid, uuid, result); + return addAttachmentAsync(current.get(), name, type, body, options, null); } - /** - * Start a new step as child of specified parent. - * - * @param parentUuid the uuid of parent test case or step. - * @param uuid the uuid of step. - * @param result the step. - */ - public void startStep(final String parentUuid, final String uuid, final StepResult result) { - notifier.beforeStepStart(result); - - result.setStage(Stage.RUNNING); - result.setStart(System.currentTimeMillis()); - - threadContext.start(uuid); - - storage.put(uuid, result); - storage.get(parentUuid, WithSteps.class).ifPresent(parentStep -> { - synchronized (storage) { - parentStep.getSteps().add(result); - } - }); - - notifier.afterStepStart(result); + private CompletableFuture addAttachmentAsync(final AllureExternalKey key, final String name, + final String type, + final CompletionStage body, + final AttachmentOptions options, + final BiConsumer onComplete) { + final Optional source = addAttachmentLink(key, name, type, options); + if (source.isEmpty()) { + return CompletableFuture.completedFuture(null); + } + final String attachmentSource = source.get(); + CompletableFuture future = body + .thenAccept(stream -> writer.write(attachmentSource, stream)) + .toCompletableFuture(); + if (Objects.nonNull(onComplete)) { + future = future.whenComplete(onComplete); + } + final Optional>> futures = futuresOf(writeOwnerOf(key, items.get(key))); + if (futures.isEmpty()) { + LOGGER.warn("Could not track async attachment: no write owner found for key {}", key); + } else { + registerFuture(futures.get(), future); + } + return future; } /** - * Updates current step. Shortcut for {@link #updateStep(String, Consumer)}. + * Adds an attachment wrapped in its own instant step under the current executable — the default representation + * for user-facing attachments. Safe to call from listener callbacks: the wrapper step emits no further events. + * Takes no effect if no executable is running. * - * @param update the update function. + * @param name the name of attachment + * @param type the content type of attachment + * @param content attachment content + * @param options the attachment options */ - public void updateStep(final Consumer update) { - final Optional current = threadContext.getCurrent(); - if (!current.isPresent()) { - LOGGER.error("Could not update step: no step running"); + public void addAttachmentStep(final String name, final String type, + final InputStream content, final AttachmentOptions options) { + final Optional current = threadContext.getCurrentExecutable(); + if (current.isEmpty()) { + LOGGER.warn(NO_CONTEXT_FOR_ATTACHMENT); + return; + } + final AllureExternalKey key = AllureExternalKey.random(AllureLifecycle.class); + startStep(current.get(), key, new StepResult().setName(attachmentStepName(name)), true); + if (!items.containsKey(key)) { return; } - final String uuid = current.get(); - updateStep(uuid, update); + try { + addAttachment(key, name, type, content, options); + updateStep(key, step -> step.setStatus(Status.PASSED)); + } catch (Throwable throwable) { + updateStep(key, step -> step + .setStatus(getStatus(throwable).orElse(Status.BROKEN)) + .setStatusDetails(getStatusDetails(throwable).orElse(null))); + throw ExceptionUtils.sneakyThrow(throwable); + } finally { + stopStep(key, true); + } } /** - * Updates step by specified uuid. + * Adds an attachment wrapped in its own instant step under the specified parent — the default representation + * for user-facing attachments. Pure manual linkage: no thread state is touched. * - * @param uuid the uuid of step. - * @param update the update function. + * @param parentKey the external parent key + * @param name the name of attachment + * @param type the content type of attachment + * @param content attachment content + * @param options the attachment options */ - public void updateStep(final String uuid, final Consumer update) { - final Optional found = storage.getStep(uuid); - if (!found.isPresent()) { - LOGGER.error("Could not update step: step with uuid {} not found", uuid); + public void addAttachmentStep(final AllureExternalKey parentKey, final String name, final String type, + final InputStream content, final AttachmentOptions options) { + final AllureExternalKey key = AllureExternalKey.random(AllureLifecycle.class); + startStep(parentKey, key, new StepResult().setName(attachmentStepName(name)), false); + if (!items.containsKey(key)) { return; } - - final StepResult step = found.get(); - - notifier.beforeStepUpdate(step); - update.accept(step); - notifier.afterStepUpdate(step); + try { + addAttachment(key, name, type, content, options); + updateStep(key, step -> step.setStatus(Status.PASSED)); + } catch (Throwable throwable) { + updateStep(key, step -> step + .setStatus(getStatus(throwable).orElse(Status.BROKEN)) + .setStatusDetails(getStatusDetails(throwable).orElse(null))); + throw ExceptionUtils.sneakyThrow(throwable); + } finally { + stopStep(key, false); + } } /** - * Stops current running step. Shortcut for {@link #stopStep(String)}. + * Adds an async attachment wrapped in its own instant step under the current executable. The attachment content + * is awaited before the owning test or scope is written; a failed body marks the step broken before the owner + * is serialized. Takes no effect if no executable is running. + * + * @param name the name of attachment + * @param type the content type of attachment + * @param body the future stream that contains attachment content + * @param options the attachment options + * @return future completed when attachment content is written */ - public void stopStep() { - final String root = threadContext.getRoot().orElse(null); - final Optional current = threadContext.getCurrent() - .filter(uuid -> !Objects.equals(uuid, root)); - if (!current.isPresent()) { - LOGGER.error("Could not stop step: no step running"); - return; + public CompletableFuture addAttachmentStepAsync(final String name, final String type, + final CompletionStage body, + final AttachmentOptions options) { + final Optional current = threadContext.getCurrentExecutable(); + if (current.isEmpty()) { + LOGGER.warn(NO_CONTEXT_FOR_ATTACHMENT); + return CompletableFuture.completedFuture(null); + } + final AllureExternalKey key = AllureExternalKey.random(AllureLifecycle.class); + final StepResult step = new StepResult() + .setName(attachmentStepName(name)) + .setStatus(Status.PASSED); + startStep(current.get(), key, step, true); + if (!items.containsKey(key)) { + return CompletableFuture.completedFuture(null); + } + try { + // the status update runs inside the tracked future, so a failed async attachment is + // guaranteed to mark the step before the owning result is written + return addAttachmentAsync(key, name, type, body, options, (result, throwable) -> { + if (Objects.nonNull(throwable)) { + step.setStatus(getStatus(throwable).orElse(Status.BROKEN)) + .setStatusDetails(getStatusDetails(throwable).orElse(null)); + } + }); + } finally { + stopStep(key, true); } - final String uuid = current.get(); - stopStep(uuid); } /** - * Stops step by given uuid. + * Adds an async attachment wrapped in its own instant step under the specified parent. Pure manual linkage: no + * thread state is touched. The attachment content is awaited before the owning test or scope is written; a + * failed body marks the step broken before the owner is serialized. * - * @param uuid the uuid of step to stop. + * @param parentKey the external parent key + * @param name the name of attachment + * @param type the content type of attachment + * @param body the future stream that contains attachment content + * @param options the attachment options + * @return future completed when attachment content is written */ - public void stopStep(final String uuid) { - final Optional found = storage.getStep(uuid); - if (!found.isPresent()) { - LOGGER.error("Could not stop step: step with uuid {} not found", uuid); - return; + public CompletableFuture addAttachmentStepAsync(final AllureExternalKey parentKey, final String name, + final String type, + final CompletionStage body, + final AttachmentOptions options) { + final AllureExternalKey key = AllureExternalKey.random(AllureLifecycle.class); + final StepResult step = new StepResult() + .setName(attachmentStepName(name)) + .setStatus(Status.PASSED); + startStep(parentKey, key, step, false); + if (!items.containsKey(key)) { + return CompletableFuture.completedFuture(null); } + try { + // the status update runs inside the tracked future, so a failed async attachment is + // guaranteed to mark the step before the owning result is written + return addAttachmentAsync(key, name, type, body, options, (result, throwable) -> { + if (Objects.nonNull(throwable)) { + step.setStatus(getStatus(throwable).orElse(Status.BROKEN)) + .setStatusDetails(getStatusDetails(throwable).orElse(null)); + } + }); + } finally { + stopStep(key, false); + } + } - final StepResult step = found.get(); - notifier.beforeStepStop(step); + private static String attachmentStepName(final String name) { + return firstNonEmpty(name).orElse("Attachment"); + } - step.setStage(Stage.FINISHED); - step.setStop(System.currentTimeMillis()); + private Optional addAttachmentLink(final AllureExternalKey key, final String name, + final String type, final AttachmentOptions options) { + Objects.requireNonNull(key, EXTERNAL_KEY); + final Object item = items.get(key); + final Object model = modelOf(item); + if (!(model instanceof WithAttachments)) { + LOGGER.warn(Objects.isNull(item) ? KEY_NOT_FOUND : WRONG_ENTITY, "add attachment", key); + return Optional.empty(); + } + final Attachment attachment = new Attachment() + .setName(firstNonEmpty(name).orElse(null)) + .setType(firstNonEmpty(type).orElse(null)) + .setSource(createAttachmentSource(type, options)); + synchronized (item) { + ((WithAttachments) model).getAttachments().add(attachment); + } + return Optional.of(attachment.getSource()); + } - storage.remove(uuid); - threadContext.stop(); + private static String createAttachmentSource(final String type, final AttachmentOptions options) { + final String extension = Optional.ofNullable(options) + .map(AttachmentOptions::getFileExtension) + .orElseGet(() -> WellKnownFileExtensionsUtils.getExtensionByMimeType(type)); + return UUID.randomUUID() + ATTACHMENT_FILE_SUFFIX + normalizeFileExtension(extension); + } - notifier.afterStepStop(step); + private static String normalizeFileExtension(final String extension) { + if (Objects.isNull(extension) || extension.isEmpty()) { + return ""; + } + return extension.charAt(0) == '.' ? extension : "." + extension; + } + + private static void registerFuture(final Set> futures, final CompletableFuture future) { + futures.add(future); + future.whenComplete((result, throwable) -> futures.remove(future)); + } + + private static void waitForFutures(final Set> futures) { + if (futures.isEmpty()) { + return; + } + final CompletableFuture[] safeFutures = futures.stream() + .map(future -> future.handle((result, throwable) -> null)) + .toArray(CompletableFuture[]::new); + CompletableFuture.allOf(safeFutures).join(); } + // ── Metadata ───────────────────────────────────────────────────────────────────────────── + /** - * Adds attachment into current test or step if any exists. Shortcut - * for {@link #addAttachment(String, String, String, InputStream)} + * Applies a metadata update to the test-level target of the calling thread's root executable: in a test, the + * test itself; in a before fixture, the fixture's scope — so the metadata propagates to every test of that + * scope when it stops. Metadata written in an after fixture is dropped by design: its tests are already + * stopped. Takes no effect if no executable is running. * - * @param name the name of attachment - * @param type the content type of attachment - * @param fileExtension the attachment file extension - * @param body attachment content + * @param update the metadata update */ - public void addAttachment(final String name, final String type, - final String fileExtension, final byte[] body) { - addAttachment(name, type, fileExtension, new ByteArrayInputStream(body)); + public void updateTestMetadata(final Consumer update) { + final Optional root = threadContext.getRoot(); + if (root.isEmpty()) { + LOGGER.warn("Could not update test metadata: no test or fixture running"); + return; + } + final Object item = items.get(root.get()); + if (item instanceof TestItem) { + updateTest(root.get(), update::accept); + return; + } + if (item instanceof FixtureItem && ScopeFixtureType.BEFORE.equals(((FixtureItem) item).type())) { + final ScopeItem scope = getItem(((FixtureItem) item).scopeKey(), ScopeItem.class, "update test metadata"); + if (Objects.nonNull(scope)) { + synchronized (scope) { + update.accept(scope.result()); + // the consumer is the only code that can break the lists-are-mutable invariant + normalizeScope(scope.result()); + } + } + } + // after-fixture metadata is dropped by design — the scope's tests are already stopped } + // ── Thread group ───────────────────────────────────────────────────────────────────────── + /** - * Adds attachment to current running test or step. + * Returns the key of the calling thread's root executable — the running test or fixture, if any. * - * @param name the name of attachment - * @param type the content type of attachment - * @param fileExtension the attachment file extension - * @param stream attachment content + * @return current test or fixture key */ - public void addAttachment(final String name, final String type, - final String fileExtension, final InputStream stream) { - writeAttachment(prepareAttachment(name, type, fileExtension), stream); + public Optional getCurrentRootKey() { + return threadContext.getRoot(); } /** - * Adds an HTTP exchange attachment. + * Returns the key of the calling thread's current executable — the attach point for new steps and attachments: a + * test, fixture, or step, if any. * - *

Build the exchange with the desired capture options before calling this method. This method only - * writes the already captured exchange.

+ *

The returned key is a manual-core identity that can be snapshotted and used later from any thread + * (capture-now-apply-later).

* - * @param name the attachment name - * @param exchange the HTTP exchange payload + * @return current executable key */ - public void addHttpExchange(final String name, final HttpExchange exchange) { - addAttachment( - name, - HttpExchange.CONTENT_TYPE, - HttpExchange.FILE_EXTENSION, - HttpExchangeSerializer.toJsonBytes(exchange) - ); + public Optional getCurrentExecutableKey() { + return threadContext.getCurrentExecutable(); } /** - * Adds attachment to current running test or step, and returns source. In order - * to store attachment content use {@link #writeAttachment(String, InputStream)} method. + * Binds the test or fixture identified by the given key as the calling thread's root, replacing any current + * binding. Use for callback-spanning context such as a test that is started and finished in separate framework + * callbacks, possibly on different threads. * - * @param name the name of attachment - * @param type the content type of attachment - * @param fileExtension the attachment file extension - * @return the source of added attachment + * @param key the external key to make current */ - public String prepareAttachment(final String name, final String type, final String fileExtension) { - final String extension = Optional.ofNullable(fileExtension) - .filter(ext -> !ext.isEmpty()) - .map(ext -> ext.charAt(0) == '.' ? ext : "." + ext) - .orElse(""); - final String source = UUID.randomUUID() + ATTACHMENT_FILE_SUFFIX + extension; - - final Optional current = threadContext.getCurrent(); - if (!current.isPresent()) { - LOGGER.error("Could not add attachment: no test is running"); - //backward compatibility: return source even if no attachment is going to be written. - return source; + public void setCurrent(final AllureExternalKey key) { + Objects.requireNonNull(key, EXTERNAL_KEY); + final Object item = items.get(key); + if (!(item instanceof TestItem) && !(item instanceof FixtureItem)) { + LOGGER.warn(Objects.isNull(item) ? KEY_NOT_FOUND : WRONG_ENTITY, "set current", key); + return; } - final Attachment attachment = new Attachment() - .setName(isEmpty(name) ? null : name) - .setType(isEmpty(type) ? null : type) - .setSource(source); - - final String uuid = current.get(); - storage.get(uuid, WithAttachments.class).ifPresent(withAttachments -> { - synchronized (storage) { - withAttachments.getAttachments().add(attachment); - } - }); - return attachment.getSource(); + threadContext.clear(); + threadContext.start(key); + } + + /** + * Clears the calling thread's binding. + */ + public void clearCurrent() { + threadContext.clear(); } /** - * Writes attachment with specified source. + * Binds the calling thread to the execution stream of the executable identified by the given key, continuing it. + * The returned binding restores the previous context when closed. * - * @param attachmentSource the source of attachment. - * @param stream the attachment content. + * @param key the external key to bind from + * @return the thread binding */ - public void writeAttachment(final String attachmentSource, final InputStream stream) { - writer.write(attachmentSource, stream); - } - - private void addFixtureToScopeOrContainer(final String scopeUuid, final String uuid, - final FixtureResult result, final ScopeFixtureType type) { - final ScopeResult scope = scopes.get(scopeUuid); - if (Objects.nonNull(scope)) { - synchronized (scope) { - normalizeScope(scope); - scope.getFixtures().add( - new ScopeFixtureResult() - .setUuid(uuid) - .setValue(result) - .setType(type) - .setScopeUuid(scopeUuid) - ); - } - return; + public AllureThreadBinding bind(final AllureExternalKey key) { + final AllureExecutionContext snapshot = snapshotOf(key, items.get(key)); + if (Objects.isNull(snapshot)) { + LOGGER.warn(KEY_NOT_FOUND, "bind", key); + threadContext.push(new AllureExecutionContext()); + } else { + threadContext.push(snapshot.copy()); } - storage.getContainer(scopeUuid).ifPresent(container -> { - synchronized (storage) { - if (ScopeFixtureType.BEFORE.equals(type)) { - container.getBefores().add(result); - } else if (ScopeFixtureType.AFTER.equals(type)) { - container.getAfters().add(result); - } - } - }); + return new ThreadBinding(threadContext); } - private void stopContainer(final String uuid) { - final Optional found = storage.getContainer(uuid); - if (!found.isPresent()) { - LOGGER.error("Could not stop test container: container with uuid {} not found", uuid); - return; + /** + * Binds a detached child context anchored to the executable identified by the given key, with an empty local + * stack. Use for independent worker-thread streams under the same executable. The returned binding restores the + * previous context when closed. + * + * @param key the external key to anchor to + * @return the thread binding + */ + public AllureThreadBinding bindDetached(final AllureExternalKey key) { + final AllureExecutionContext snapshot = snapshotOf(key, items.get(key)); + if (Objects.isNull(snapshot)) { + LOGGER.warn(KEY_NOT_FOUND, "bind detached", key); + threadContext.push(new AllureExecutionContext()); + } else { + threadContext.push(snapshot.copy().child()); } - final TestResultContainer container = found.get(); - notifier.beforeContainerStop(container); - container.setStop(System.currentTimeMillis()); - notifier.afterContainerStop(container); + return new ThreadBinding(threadContext); } - private void writeContainer(final String uuid) { - final Optional found = storage.getContainer(uuid); - if (!found.isPresent()) { - LOGGER.error("Could not write test container: container with uuid {} not found", uuid); - return; - } - final TestResultContainer container = found.get(); - writeContainer(container); + // ── Internals ──────────────────────────────────────────────────────────────────────────── - storage.remove(uuid); - scopeMetadata.remove(uuid); - scopeParents.remove(uuid); + private T getItem(final AllureExternalKey key, final Class type, final String operation) { + Objects.requireNonNull(key, EXTERNAL_KEY); + final Object item = items.get(key); + if (Objects.isNull(item)) { + LOGGER.warn(KEY_NOT_FOUND, operation, key); + return null; + } + if (!type.isInstance(item)) { + LOGGER.warn(WRONG_ENTITY, operation, key); + return null; + } + return type.cast(item); } - private void writeContainer(final TestResultContainer container) { - notifier.beforeContainerWrite(container); - writer.write(container); - notifier.afterContainerWrite(container); + private boolean isCurrentRoot(final AllureExternalKey key) { + return threadContext.getRoot().filter(key::equals).isPresent(); } - private TestResultContainer toScopeContainer(final ScopeResult scope) { - final List children = new ArrayList<>(); - children.addAll(scope.getChildScopes()); - children.addAll(scope.getTestChildren()); - if (children.isEmpty()) { - children.addAll(scope.getTests()); + private static Object modelOf(final Object item) { + if (item instanceof TestItem) { + return ((TestItem) item).result(); } - final TestResultContainer container = new TestResultContainer() - .setUuid(scope.getUuid()) - .setName(scope.getName()) - .setChildren(new ArrayList<>(new LinkedHashSet<>(children))); - for (ScopeFixtureResult fixture : scope.getFixtures()) { - final FixtureResult result = fixture.getValue(); - if (Objects.isNull(result)) { - continue; - } - if (ScopeFixtureType.BEFORE.equals(fixture.getType())) { - container.getBefores().add(result); - } else if (ScopeFixtureType.AFTER.equals(fixture.getType())) { - container.getAfters().add(result); - } + if (item instanceof FixtureItem) { + return ((FixtureItem) item).result(); } - return container; + if (item instanceof StepItem) { + return ((StepItem) item).result(); + } + if (item instanceof ScopeItem) { + return ((ScopeItem) item).result(); + } + return null; } - private void normalizeScope(final ScopeResult scope) { - scope.setTests(mutableList(scope.getTests())); - scope.setChildScopes(mutableList(scope.getChildScopes())); - scope.setTestChildren(mutableList(scope.getTestChildren())); - scope.setFixtures(mutableList(scope.getFixtures())); - scope.setLabels(mutableList(scope.getLabels())); - scope.setLinks(mutableList(scope.getLinks())); - scope.setParameters(mutableList(scope.getParameters())); + /** + * Returns the execution context an item anchors: tests and fixtures always anchor {@code [key]} (start binds + * them as the fresh root, so their snapshot is deterministic and never stored); steps carry the snapshot taken + * at their start. + */ + private static AllureExecutionContext snapshotOf(final AllureExternalKey key, final Object item) { + if (item instanceof TestItem || item instanceof FixtureItem) { + final AllureExecutionContext context = new AllureExecutionContext(); + context.start(key); + return context; + } + if (item instanceof StepItem) { + return ((StepItem) item).contextSnapshot(); + } + return null; } - private List mutableList(final List values) { - return Objects.isNull(values) ? new ArrayList<>() : new ArrayList<>(values); + private static AllureExternalKey writeOwnerOf(final AllureExternalKey key, final Object item) { + if (item instanceof TestItem) { + return key; + } + if (item instanceof FixtureItem) { + return ((FixtureItem) item).scopeKey(); + } + if (item instanceof StepItem) { + return ((StepItem) item).writeOwnerKey(); + } + if (item instanceof ScopeItem) { + return key; + } + return null; } - private void updateTestOrBeforeFixtureScope(final String metadataName, - final Consumer testUpdate, - final Consumer scopeUpdate, - final Consumer legacyScopeUpdate) { - final Optional root = threadContext.getRoot(); - if (!root.isPresent()) { - LOGGER.error("Could not update test metadata: no test or before fixture running"); - return; + private Optional>> futuresOf(final AllureExternalKey ownerKey) { + if (Objects.isNull(ownerKey)) { + return Optional.empty(); } - - final String uuid = root.get(); - final Optional testResult = storage.getTestResult(uuid); - if (testResult.isPresent()) { - updateTestCase(uuid, testUpdate); - return; + final Object item = items.get(ownerKey); + if (item instanceof TestItem) { + return Optional.of(((TestItem) item).futures()); } - - final FixtureContext fixtureContext = fixtureContexts.get(uuid); - if (Objects.isNull(fixtureContext)) { - LOGGER.error("Could not add {} metadata: current root {} is not a test or fixture", metadataName, uuid); - return; + if (item instanceof ScopeItem) { + return Optional.of(((ScopeItem) item).futures()); } + return Optional.empty(); + } - if (ScopeFixtureType.AFTER.equals(fixtureContext.type())) { - LOGGER.error("Could not add {} metadata: after fixture metadata is not supported", metadataName); - return; - } + private static AllureExecutionContext deriveSnapshot(final AllureExternalKey parentKey, final Object parentItem, + final AllureExternalKey key) { + final AllureExecutionContext parentSnapshot = snapshotOf(parentKey, parentItem); + final AllureExecutionContext snapshot = Objects.isNull(parentSnapshot) + ? new AllureExecutionContext() + : parentSnapshot.copy(); + snapshot.start(key); + return snapshot; + } - final ScopeResult scope = scopes.get(fixtureContext.scopeUuid()); - if (Objects.nonNull(scope)) { - synchronized (scope) { - normalizeScope(scope); - scopeUpdate.accept(scope); - } + private void sweepOwnedSteps(final AllureExternalKey ownerKey) { + items.entrySet().removeIf(entry -> entry.getValue() instanceof StepItem + && ownerKey.equals(((StepItem) entry.getValue()).writeOwnerKey())); + } + + private void addTest(final ScopeItem scope, final String testUuid) { + if (firstNonEmpty(testUuid).isEmpty()) { return; } - - final ScopeMetadata metadata = scopeMetadata.computeIfAbsent( - fixtureContext.scopeUuid(), - key -> new ScopeMetadata() - ); - synchronized (metadata) { - legacyScopeUpdate.accept(metadata); + synchronized (scope) { + if (!scope.result().getTests().contains(testUuid)) { + scope.result().getTests().add(testUuid); + } } } - private void applyScopeMetadata(final TestResult testResult) { - final Set linkedScopes = testScopes.get(testResult.getUuid()); - if (Objects.isNull(linkedScopes)) { - return; - } - linkedScopes.forEach(scopeUuid -> { - final ScopeResult scope = scopes.get(scopeUuid); - if (Objects.nonNull(scope)) { - synchronized (scope) { - normalizeScope(scope); - applyScopeMetadata(scope, testResult); - } - return; + private void applyScopeMetadata(final TestItem item) { + for (final AllureExternalKey scopeKey : List.copyOf(item.scopes())) { + // claim the link before merging, so a concurrent writeScope drain cannot apply it twice + if (!item.scopes().remove(scopeKey)) { + continue; } - final ScopeMetadata metadata = scopeMetadata.get(scopeUuid); - if (Objects.nonNull(metadata)) { - synchronized (metadata) { - metadata.applyTo(testResult); + final Object found = items.get(scopeKey); + if (found instanceof ScopeItem) { + final ScopeItem scope = (ScopeItem) found; + synchronized (scope) { + mergeScopeMetadata(scope.result(), item.result()); } } - }); + } } - private void applyScopeMetadata(final ScopeResult scope, final TestResult testResult) { + private static void mergeScopeMetadata(final ScopeResult scope, final TestResult testResult) { testResult.getLabels().addAll(scope.getLabels()); testResult.getLinks().addAll(scope.getLinks()); testResult.getParameters().addAll(scope.getParameters()); @@ -1082,107 +1286,94 @@ private void applyScopeMetadata(final ScopeResult scope, final TestResult testRe } } - private void linkExistingTests(final ScopeResult scope) { - scope.getTests().forEach(childUuid -> linkTestToScope(scope.getUuid(), childUuid)); - } - - private void linkExistingChildren(final TestResultContainer container) { - container.getChildren().forEach(childUuid -> linkTestToScope(container.getUuid(), childUuid)); - } - - private void linkTestToScope(final String scopeUuid, final String testUuid) { - if (isEmpty(scopeUuid) || isEmpty(testUuid)) { - return; - } - final Set linkedScopes = testScopes.computeIfAbsent(testUuid, key -> new CopyOnWriteArraySet<>()); - final Set visitedScopes = new HashSet<>(); - String current = scopeUuid; - while (Objects.nonNull(current) && visitedScopes.add(current)) { - linkedScopes.add(current); - addTestToScope(current, testUuid); - current = scopeParents.get(current); - } - } - - private void addTestToScope(final String scopeUuid, final String testUuid) { - final ScopeResult scope = scopes.get(scopeUuid); - if (Objects.isNull(scope)) { - return; - } - synchronized (scope) { - normalizeScope(scope); - if (!scope.getTests().contains(testUuid)) { - scope.getTests().add(testUuid); + private static TestResultContainer toScopeContainer(final ScopeResult scope) { + final TestResultContainer container = new TestResultContainer() + .setUuid(scope.getUuid()) + .setChildren(new ArrayList<>(new LinkedHashSet<>(scope.getTests()))); + for (ScopeFixtureResult fixture : scope.getFixtures()) { + final FixtureResult result = fixture.getValue(); + if (Objects.isNull(result)) { + continue; + } + if (ScopeFixtureType.BEFORE.equals(fixture.getType())) { + container.getBefores().add(result); + } else if (ScopeFixtureType.AFTER.equals(fixture.getType())) { + container.getAfters().add(result); } } + return container; } - private void addTestChildToScope(final String scopeUuid, final String testUuid) { - final ScopeResult scope = scopes.get(scopeUuid); - if (Objects.isNull(scope)) { - return; - } - synchronized (scope) { - normalizeScope(scope); - if (!scope.getTestChildren().contains(testUuid)) { - scope.getTestChildren().add(testUuid); - } - } + /** + * Re-establishes the internal invariant that all scope lists are non-null and mutable. The model guarantees it + * at construction and every internal mutation preserves it; the only code that can break it is the user-supplied + * metadata consumer, so this runs once right after that consumer — never defensively anywhere else. Copying also + * detaches any list alias the consumer may have retained. + */ + private static void normalizeScope(final ScopeResult scope) { + scope.setTests(new ArrayList<>(Objects.requireNonNullElse(scope.getTests(), List.of()))); + scope.setFixtures(new ArrayList<>(Objects.requireNonNullElse(scope.getFixtures(), List.of()))); + scope.setLabels(new ArrayList<>(Objects.requireNonNullElse(scope.getLabels(), List.of()))); + scope.setLinks(new ArrayList<>(Objects.requireNonNullElse(scope.getLinks(), List.of()))); + scope.setParameters(new ArrayList<>(Objects.requireNonNullElse(scope.getParameters(), List.of()))); } - private void restoreContextAfterFixture(final String uuid) { - final FixtureContext fixtureContext = fixtureContexts.remove(uuid); - if (Objects.isNull(fixtureContext)) { - threadContext.clear(); - return; + // ── Items ──────────────────────────────────────────────────────────────────────────────── + + /** + * Internal item of a scheduled or running test: the result model, the scopes the test is linked to (their + * metadata is merged into the test at stop), and the async attachment futures awaited before the test is written. + */ + private record TestItem(TestResult result, Set scopes, Set> futures) { + + private TestItem(final TestResult result) { + this(result, ConcurrentHashMap.newKeySet(), ConcurrentHashMap.newKeySet()); } - threadContext.set(fixtureContext.previousContext()); } - private boolean isEmpty(final String s) { - return Objects.isNull(s) || s.isEmpty(); + /** + * Internal item of a running fixture: the result model, the owning scope, the fixture type, and the calling + * thread's binding saved at start — restored by stop when the fixture is still the thread's root. + */ + private record FixtureItem(FixtureResult result, AllureExternalKey scopeKey, ScopeFixtureType type, + AllureExecutionContext savedContext) { } - private record FixtureContext(String scopeUuid, ScopeFixtureType type, Deque previousContext) { + /** + * Internal item of a running step: the result model, the write owner (the test or scope whose write awaits the + * step's async attachments), the execution context captured at start (feeds {@code bind}/{@code bindDetached}), + * and whether the step is a stage. + */ + private record StepItem(StepResult result, AllureExternalKey writeOwnerKey, + AllureExecutionContext contextSnapshot, boolean stage) { } - private static final class ScopeMetadata { - - private final List