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..7c2ec119d 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,72 @@
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 +146,1143 @@ 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 extends InputStream> 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 extends InputStream> 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 extends InputStream> 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 extends InputStream> 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 extends InputStream> 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);
}
/**
- * Writes attachment with specified source.
- *
- * @param attachmentSource the source of attachment.
- * @param stream the attachment content.
+ * Clears the calling thread's 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;
- }
- 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);
- }
- }
- });
+ public void clearCurrent() {
+ threadContext.clear();
}
- 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 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 key the external key to bind from
+ * @return the thread binding
+ */
+ 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());
}
- 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;
+ /**
+ * 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();
- writeContainer(container);
-
- storage.remove(uuid);
- scopeMetadata.remove(uuid);
- scopeParents.remove(uuid);
+ return new ThreadBinding(threadContext);
}
- private void writeContainer(final TestResultContainer container) {
- notifier.beforeContainerWrite(container);
- writer.write(container);
- notifier.afterContainerWrite(container);
- }
+ // ── Internals ────────────────────────────────────────────────────────────────────────────
- 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 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;
}
- 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 (!type.isInstance(item)) {
+ LOGGER.warn(WRONG_ENTITY, operation, key);
+ return null;
}
- return container;
+ return type.cast(item);
}
- 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()));
+ private boolean isCurrentRoot(final AllureExternalKey key) {
+ return threadContext.getRoot().filter(key::equals).isPresent();
}
- private List mutableList(final List values) {
- return Objects.isNull(values) ? new ArrayList<>() : new ArrayList<>(values);
+ private static Object modelOf(final Object item) {
+ if (item instanceof TestItem) {
+ return ((TestItem) item).result();
+ }
+ if (item instanceof FixtureItem) {
+ return ((FixtureItem) item).result();
+ }
+ if (item instanceof StepItem) {
+ return ((StepItem) item).result();
+ }
+ if (item instanceof ScopeItem) {
+ return ((ScopeItem) item).result();
+ }
+ 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;
+ /**
+ * 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;
}
-
- final String uuid = root.get();
- final Optional testResult = storage.getTestResult(uuid);
- if (testResult.isPresent()) {
- updateTestCase(uuid, testUpdate);
- return;
+ if (item instanceof StepItem) {
+ return ((StepItem) item).contextSnapshot();
}
+ return null;
+ }
- 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;
+ private static AllureExternalKey writeOwnerOf(final AllureExternalKey key, final Object item) {
+ if (item instanceof TestItem) {
+ return key;
}
-
- if (ScopeFixtureType.AFTER.equals(fixtureContext.type())) {
- LOGGER.error("Could not add {} metadata: after fixture metadata is not supported", metadataName);
- return;
+ if (item instanceof FixtureItem) {
+ return ((FixtureItem) item).scopeKey();
+ }
+ if (item instanceof StepItem) {
+ return ((StepItem) item).writeOwnerKey();
}
+ if (item instanceof ScopeItem) {
+ return key;
+ }
+ return null;
+ }
- final ScopeResult scope = scopes.get(fixtureContext.scopeUuid());
- if (Objects.nonNull(scope)) {
- synchronized (scope) {
- normalizeScope(scope);
- scopeUpdate.accept(scope);
- }
- return;
+ private Optional>> futuresOf(final AllureExternalKey ownerKey) {
+ if (Objects.isNull(ownerKey)) {
+ return Optional.empty();
+ }
+ final Object item = items.get(ownerKey);
+ if (item instanceof TestItem) {
+ return Optional.of(((TestItem) item).futures());
}
+ if (item instanceof ScopeItem) {
+ return Optional.of(((ScopeItem) item).futures());
+ }
+ return Optional.empty();
+ }
- final ScopeMetadata metadata = scopeMetadata.computeIfAbsent(
- fixtureContext.scopeUuid(),
- key -> new ScopeMetadata()
+ 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;
+ }
+
+ private void sweepOwnedSteps(final AllureExternalKey ownerKey) {
+ items.entrySet().removeIf(
+ entry -> entry.getValue() instanceof StepItem
+ && ownerKey.equals(((StepItem) entry.getValue()).writeOwnerKey())
);
- synchronized (metadata) {
- legacyScopeUpdate.accept(metadata);
- }
}
- private void applyScopeMetadata(final TestResult testResult) {
- final Set linkedScopes = testScopes.get(testResult.getUuid());
- if (Objects.isNull(linkedScopes)) {
+ private void addTest(final ScopeItem scope, final String testUuid) {
+ if (firstNonEmpty(testUuid).isEmpty()) {
return;
}
- linkedScopes.forEach(scopeUuid -> {
- final ScopeResult scope = scopes.get(scopeUuid);
- if (Objects.nonNull(scope)) {
- synchronized (scope) {
- normalizeScope(scope);
- applyScopeMetadata(scope, testResult);
- }
- return;
+ synchronized (scope) {
+ if (!scope.result().getTests().contains(testUuid)) {
+ scope.result().getTests().add(testUuid);
}
- final ScopeMetadata metadata = scopeMetadata.get(scopeUuid);
- if (Objects.nonNull(metadata)) {
- synchronized (metadata) {
- metadata.applyTo(testResult);
+ }
+ }
+
+ 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 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 +1294,95 @@ 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