@@ -206,7 +206,6 @@ public void writeScope(final AllureExternalKey key) {
206206 }
207207 final TestResultContainer container ;
208208 synchronized (scope ) {
209- normalizeScope (scope .result ());
210209 container = toScopeContainer (scope .result ());
211210 }
212211 notifier .beforeContainerWrite (container );
@@ -400,7 +399,6 @@ private void startFixture(final AllureExternalKey scopeKey, final AllureExternal
400399 return ;
401400 }
402401 synchronized (scope ) {
403- normalizeScope (scope .result ());
404402 scope .result ().getFixtures ().add (
405403 new ScopeFixtureResult ()
406404 .setUuid (UUID .randomUUID ().toString ())
@@ -641,35 +639,24 @@ public void updateStep(final AllureExternalKey key, final Consumer<StepResult> u
641639 }
642640
643641 /**
644- * Updates current step. Stages are transparent to this: the update targets the deepest non-stage step, so a
645- * caller finishing its own step is not misdirected onto a stage the user opened inside it.
642+ * Updates the current running step. A stage cannot be updated: stages are addressed by nobody once started, so
643+ * when the current step is a stage this warns and does nothing — a caller finishing its own step must address
644+ * it by key.
646645 *
647646 * @param update the update function.
648647 */
649648 public void updateStep (final Consumer <StepResult > update ) {
650- final Optional <AllureExternalKey > current = currentNonStageStep ();
649+ final Optional <AllureExternalKey > current = threadContext . getCurrentStep ();
651650 if (current .isEmpty ()) {
652651 LOGGER .warn ("Could not update step: no step running" );
653652 return ;
654653 }
655- updateStep (current .get (), update );
656- }
657-
658- private Optional <AllureExternalKey > currentNonStageStep () {
659- final AllureExternalKey root = threadContext .getRoot ().orElse (null );
660- for (final AllureExternalKey key : threadContext .getLocalKeys ()) {
661- if (Objects .equals (key , root )) {
662- return Optional .empty ();
663- }
664- final Object item = items .get (key );
665- if (!(item instanceof StepItem )) {
666- return Optional .empty ();
667- }
668- if (!((StepItem ) item ).stage ()) {
669- return Optional .of (key );
670- }
654+ final Object item = items .get (current .get ());
655+ if (item instanceof StepItem && ((StepItem ) item ).stage ()) {
656+ LOGGER .warn ("Could not update step: the current step is a stage" );
657+ return ;
671658 }
672- return Optional . empty ( );
659+ updateStep ( current . get (), update );
673660 }
674661
675662 /**
@@ -1065,53 +1052,35 @@ private static void waitForFutures(final Set<CompletableFuture<?>> futures) {
10651052 // ── Metadata ─────────────────────────────────────────────────────────────────────────────
10661053
10671054 /**
1068- * Resolves the metadata target for the given executable key.
1055+ * Applies a metadata update to the test-level target of the calling thread's root executable: in a test, the
1056+ * test itself; in a before fixture, the fixture's scope — so the metadata propagates to every test of that
1057+ * scope when it stops. Metadata written in an after fixture is dropped by design: its tests are already
1058+ * stopped. Takes no effect if no executable is running.
10691059 *
1070- * <p>Pure key-to-key mapping with no thread state: a test resolves to itself; a before fixture resolves to its
1071- * scope (so before-fixture metadata propagates to the scope's tests); an after fixture, or anything else,
1072- * resolves to empty.</p>
1073- *
1074- * @param key the executable key
1075- * @return the metadata target key, or empty if the executable cannot carry metadata
1076- */
1077- public Optional <AllureExternalKey > metadataTarget (final AllureExternalKey key ) {
1078- Objects .requireNonNull (key , EXTERNAL_KEY );
1079- final Object item = items .get (key );
1080- if (item instanceof TestItem ) {
1081- return Optional .of (key );
1082- }
1083- if (item instanceof FixtureItem && ScopeFixtureType .BEFORE .equals (((FixtureItem ) item ).type ())) {
1084- return Optional .of (((FixtureItem ) item ).scopeKey ());
1085- }
1086- return Optional .empty ();
1087- }
1088-
1089- /**
1090- * Applies a metadata update to the test or scope identified by the given key.
1091- *
1092- * <p>Generic, key-addressed, and thread-free: it dispatches on the stored item behind the key. Higher-level
1093- * facades resolve the current target (see {@link #metadataTarget(AllureExternalKey)}) and pass the mutation
1094- * here.</p>
1095- *
1096- * @param key the metadata target key (a test or scope)
10971060 * @param update the metadata update
10981061 */
1099- public void updateMetadata (final AllureExternalKey key , final Consumer <WithMetadata > update ) {
1100- Objects .requireNonNull (key , EXTERNAL_KEY );
1101- final Object item = items .get (key );
1102- if (item instanceof ScopeItem ) {
1103- final ScopeItem scope = (ScopeItem ) item ;
1104- synchronized (scope ) {
1105- normalizeScope (scope .result ());
1106- update .accept (scope .result ());
1107- }
1062+ public void updateTestMetadata (final Consumer <WithMetadata > update ) {
1063+ final Optional <AllureExternalKey > root = threadContext .getRoot ();
1064+ if (root .isEmpty ()) {
1065+ LOGGER .warn ("Could not update test metadata: no test or fixture running" );
11081066 return ;
11091067 }
1068+ final Object item = items .get (root .get ());
11101069 if (item instanceof TestItem ) {
1111- updateTest (key , update ::accept );
1070+ updateTest (root . get () , update ::accept );
11121071 return ;
11131072 }
1114- LOGGER .warn ("Could not update metadata: item with key {} is not a test or scope" , key );
1073+ if (item instanceof FixtureItem && ScopeFixtureType .BEFORE .equals (((FixtureItem ) item ).type ())) {
1074+ final ScopeItem scope = getItem (((FixtureItem ) item ).scopeKey (), ScopeItem .class , "update test metadata" );
1075+ if (Objects .nonNull (scope )) {
1076+ synchronized (scope ) {
1077+ update .accept (scope .result ());
1078+ // the consumer is the only code that can break the lists-are-mutable invariant
1079+ normalizeScope (scope .result ());
1080+ }
1081+ }
1082+ }
1083+ // after-fixture metadata is dropped by design — the scope's tests are already stopped
11151084 }
11161085
11171086 // ── Thread group ─────────────────────────────────────────────────────────────────────────
@@ -1303,7 +1272,6 @@ private void addChild(final ScopeItem scope, final String childUuid) {
13031272 return ;
13041273 }
13051274 synchronized (scope ) {
1306- normalizeScope (scope .result ());
13071275 if (!scope .result ().getTests ().contains (childUuid )) {
13081276 scope .result ().getTests ().add (childUuid );
13091277 }
@@ -1316,7 +1284,6 @@ private void applyScopeMetadata(final TestItem item) {
13161284 if (found instanceof ScopeItem ) {
13171285 final ScopeItem scope = (ScopeItem ) found ;
13181286 synchronized (scope ) {
1319- normalizeScope (scope .result ());
13201287 mergeScopeMetadata (scope .result (), item .result ());
13211288 }
13221289 }
@@ -1353,6 +1320,12 @@ private static TestResultContainer toScopeContainer(final ScopeResult scope) {
13531320 return container ;
13541321 }
13551322
1323+ /**
1324+ * Re-establishes the internal invariant that all scope lists are non-null and mutable. The model guarantees it
1325+ * at construction and every internal mutation preserves it; the only code that can break it is the user-supplied
1326+ * metadata consumer, so this runs once right after that consumer — never defensively anywhere else. Copying also
1327+ * detaches any list alias the consumer may have retained.
1328+ */
13561329 private static void normalizeScope (final ScopeResult scope ) {
13571330 scope .setTests (mutableList (scope .getTests ()));
13581331 scope .setFixtures (mutableList (scope .getFixtures ()));
0 commit comments